학습자료(~2017)/팁

디버깅 방법

단세포소년 2012. 7. 26. 10:57
반응형

출처 : http://www.pmguda.com/72


디버깅은 프로그래밍을 시작하는 사람이면 누구나 동시에 시작하는 작업입니다. 디버깅을 시작하는 사람들을 위해 프로그래밍을 시작하는 처음부터 어떤 태도를 가져야 할 지, 어떤 것을 알아야 할 지 알아보도록 하겠습니다. 대중적인 플랫폼을 크게 두 개로 보았을 때, 디버깅은 윈도우 계열과 유닉스 계열의 큰 차이는 없습니다. 다만, 그 툴이 현저히 달라서 두 계열 동시에 비슷한 기능을 하는 디버깅 툴을 소개한다는 것은 불가능한 일입니다. 하지만 프로그램을 작성할 때부터 디버깅을 염두에 두고 프로그램을 작성하는 면에서는 크게 다르지 않습니다. 이 글에서는 프로그램이 만들어지는 순간들을 살펴보면서, ‘디버깅을 위한 프로그래밍 습관’에 대해 모든 환경에서 주의해야할 디버깅 기법에 대해 정리하고자 합니다. 참고로, 필자는 유닉스 기반의 서버 프로그램을 작성 및 포팅하는 것을 전문으로 하고 있습니다. 디버깅을 너무 일반적으로 할 수는 없는 것이므로, 연재를 통틀어 C/C++를 기반으로 전개할 것입니다.

언어와 개발 환경
다음의 몇 가지 이야기는 디버깅을 잘하는 것은 올바른 배움의 자세에서 나온다는 것을 전제로 생각해 보기 위한 글입니다.
반쪽 프로그래머
프로그래밍을 처음 배우는 사람은 C++, 자바, HTML 등을 배우게 됩니다. 즉, 언어를 배우게 되는 것이죠. 여기에 디버깅을 생각하면, 참으로 뛰어넘기 어려운 커리큘럼의 한계에 부딪히게 됩니다. 배울 때는 언어만 배우면 될 것 같지만 언어의 문법을 익히는 것만으로는 50점입니다. 그와 쌍벽을 이루는 것은 환경이라 할 수 있습니다. 즉 OS, 프로토콜, 라이브러리 등을 말합니다. 실전에서 부딪히는 ‘디버깅의 문제’는 항상 언어와 환경에 대한 이해도를 동시에 측정하는 문제와 같습니다. 어느 하나만 물어보는 문제는 사실 그리 많지 않습니다. 문법을 익히는 것으로도 어려운 사람에게 OS와 원하는 환경에 대한 API들(소켓, DB, 멀티미디어, MAPI, IPC 등)을 익혀야 한다는 사실은 프로그래머의 길을 참으로 멀게 느껴지게 하는 요소가 됩니다. 하지만 진실을 알아야 제대로 길을 가겠지요.
창을 제어하는 것을 만들 때, MFC를 사용할 것이냐와 볼랜드의 OWL을 사용하느냐는 ‘라이브러리’의 문제가 됩니다. 물론 컴파일러와 동반된 라이브러리라는 점에서는 ‘컴파일러’의 문제일 수도 있습니다만, 좀더 멀리 윈도우와 유닉스 계열에 동시 사용되는 Qt 라이브러리를 사용하느냐, 유닉스 상에서 사용되는 구식의 Motif 라이브러리를 사용할 것이냐 등의 문제까지 확장한다면, 이들을 사용할 때의 언어는 C/C++를 통해 비슷한 문법을 사용하지만 ‘사용되는 OS 플랫폼’과 ‘라이브러리’의 문제가 됩니다.
중요한 것은 언급한 OS와 컴파일러 등에 따라 라이브러리를 선택해야만 하지만, 이들 라이브러리들이 만들어지게 된 동기나 UI 및 처리 방식 등은 비슷하다는 것입니다. OS, 컴파일러, 라이브러리는 상호 호환되지 않지만 시작과 끝은 비슷한 인터페이스와 구현 개념으로 시작해 C/C++라는 언어까지 비슷한 체계를 만들어 냅니다. 이런 유사성이 많은 라이브러리가 있다는 것은 알지만 그것을 공부하는 것은 어렵습니다. 그럴지라도 빠지지 말아야 할 오류 중의 하나는, 언어와 라이브러리의 정확한 경계를 파악하면서 배우자입니다.
흔히 잘못 알려진 예를 들어보기로 합시다. 웹 프로그래밍을 할 때, ASP는 사실 IIS에서 CGI를 잘 구현하기 위한 몇 가지 객체에 대한 정의입니다. 그 객체는 언어와 독립된 존재입니다. 따라서 VBScript는 ASP와 분리되어 생각해야 하지요. JScript로 ASP를 작성할 수도 있습니다. 그러나 ‘ASP로 만들었어’라는 말에는 ‘VBScript로 만들었어’를 함축하여 사용하게 됩니다. 언어와 환경을 분리해 공부하는 학습을 해 두는 것이 앞서 언급한 언어와 환경을 동시에 묻는 ‘디버깅 문제’를 잘 해결하는 지름길입니다.

언어와 표준 라이브러리
주로 C, C++를 배울 때 나오는 문제입니다. 사실 C 언어와 동시대에 만들어지고 살아남은 언어는 없다고 해도 과언이 아닐 정도입니다. C 언어는 그간 많은 정제 작업을 거쳐 표준화되었고, 어떤 플랫폼이 새로 나올 때 어셈블리 다음으로 가장 먼저 포팅이 되는 언어라 할 수 있습니다. 그만큼 언어를 통해 이루어 놓은 재원이 풍부하다는 것이지요. 요즘에 새로이 나오는 언어들은 대개 표준화된 라이브러리를 포함해 배포가 됩니다. 하지만 C는 표준화 작업에서 ‘C 라이브러리’라는 표준화된 라이브러리를 선택하게 되었고, 컴파일러는 표준 C 라이브러리와 OS 자체 라이브러리를 동시에 배포하게 됩니다. 그러다보니 처음 언어를 배우는 사람들이 문법과 라이브러리의 차이를 알면서 배우기란 참 힘듭니다.
for와 printf 예를 들어 봅시다. for는 C 언어를 이루는 구문이며, printf는 라이브러리 함수라는 큰 차이가 있습니다. 이러한 차이는 처리 관점에서 볼 때, for는 컴파일러가 해석해 루프 코드를 만들어 내고, printf에 대해서는 심볼을 찾아 호출(call)할 수 있는 방법으로 처리됩니다. 링커는 for에 대해서는 아무것도 하지 않으며, printf에 대해서는 외부 라이브러리에서 심볼을 찾아다가 점프 테이블을 갱신해 줍니다. printf는 C 언어 문법 명세에 있는 것이 아닙니다. 표준 라이브러리에 들어 있는 것입니다. sizeof는 함수인가요? 연산자입니다. 의심스러운 분은 찾아보기 바랍니다. printf의 구현은 DOS에서 다르고, MS 윈도우에서 다르고, 모바일 폰에서 다릅니다. 하지만 C 표준 문서는 printf의 선언에 대한 명확한 정의를 하고 있습니다. C가 놀라운 이식성을 가진다는 것은 플랫폼이 기본적으로 지원하는 언어이며, 지원시 표준 라이브러리 명세에 들어 있는 것을 해당 플랫폼에 맞게 구현해 놓았다는 것에 있습니다. 다시 한번 디버깅을 위한 기본 자세는 언어와 환경을 잘 구별하는 것에 있음을 강조하고 싶습니다.

#구문과 함수
#include
int main( void ) /* main : 라이브러리 함수 */
{
int i;
for( i=0; i<10; i++ )
{
printf("%d Hello, world? %d", i, sizeof( i ) );
} /* printf : 라이브러리 함수, sizeof : 연산자) */
return 0; /* return : 구문 */
}

요즘의 에디터는 함수와 구문/연산자에 대해 색깔을 다르게 표시해 줍니다. 구문 컬러링(syntax coloring)은 90년대 초반 볼랜드의 터보 C++ 이후로 프로그램 소스 에디터의 거의 필수적인 요소가 되어 있습니다. 유닉스에서도 Emacs와 vim을 쓰는 분들도 구문 컬러링을 위해 컬러가 지원되는 터미널 혹은 GUI 버전을 사용하는 것이 추세입니다. <리스트 1>을 보면, 구문 컬러링이 main에 대해서는 printf와 같이 하는 것을 보기도 할 텐데요. main은 단순한 콜백 함수일 뿐입니다. C 언어는 C start-up object가 있어서 OS에서 프로세스를 실행할 때 초기화하는 코드가 먼저 불려지고, 이 코드는 main이라는 외부 함수를 호출하게 되어 있습니다. 따라서 외부 프로그램은 항상 main부터 시작하게 되는 것이지요. 이것은 링커의 규약이 아니며, 단지 C start-up object에서 그것을 요구하기 때문일 뿐입니다. 링커는 C 언어와는 상관없이 객체간에 undefined symbol에 대해 다른 라이브러리나 객체에서 익스포트 심볼을 찾아 채워주는 일을 합니다.
<리스트 1>에서 sizeof를 잘 살펴보면, sizeof가 함수일 경우 그것은 링커에 의해 관심 대상이 될 것입니다. 하지만 sizeof는 컴파일 타임에서 그 값이 결정되는 단항 연산자이며, 결과는 상수입니다. 즉, 프로그램 중간에 바뀌지 않는다는 것이지요.


OS 편애 금지
모름지기 프로그래머라면, 플랫폼과 언어 선택에 있어서 운신의 폭을 좁히는 것은 ‘깊은 이해’를 끊는 것이나 다름없습니다. 종종 비아냥 투로 이런 류의 얘기를 많이 듣습니다. “앞으로도 성공할 리 없는 리눅스는 관심 없어. 난 윈도우 프로그래머니까”, “툭하면 파란 화면 뜨는 것이 OS냐?” 이런 플랫폼 고착적인 자세는 프로그래머로서의 도리가 아닙니다. 프로그래머는 플랫폼을 가리지 말고 도전할 때 운용체계와 라이브러리에 대한 깊은 이해가 생기게 됩니다. 모든 OS는 나름대로의 위치가 있습니다. 나름대로의 노하우를 정리해 두는 것이 앞으로 30년 뒤에 나올 OS도 문제없이 프로그래밍할 대상이 될 수 있을 것입니다. 앞으로 20년 뒤, “내가 xxx와 yyy에서 20년간 프로그래밍을 해보니 yyy는 OS로서는 미흡하다”는 말을 할 수 있기를 바랍니다. 디버깅을 잘하는 사람의 특징은 플랫폼과 라이브러리에 대한 겸손과 섬세한 이해라고나 해야 할까요?

API에 대한 경외심과 답답함
디버깅 초보가 겪는 문제 중의 하나는 널리 알려진 API에 대한 경외심 혹은 그 반대의 답답함에 있습니다. API라는 것은 말 그대로 Application Programming Interface입니다. 블랙박스를 사용하는 방법에 대한 문서라고 할 수 있습니다. API에 대한 경외심은 개발자가 잘 모르는 영역에 대한 API인 경우가 많습니다. 파일 시스템 핸들링 API, 윈도우 메시지 API, 프로세스간 데이터 교환을 돕기 위한 IPC 등 시스템 레벨인 경우에는 안정적일 것이라는 막연한 생각에서 인정하고 사용합니다.
반면, 답답함은 버그가 발생하고, 문제가 없을 듯해 보이는 방법이 전혀 해결될 기미가 보이지 않을 때 일단 자신의 실력을 의심하다가 나중에는 API에 버그가 있을지도 모른다는 생각을 하게 됩니다. 특히, 최근에 나온 소프트웨어에 대한 것일수록 그런 의심을 하게 됩니다. “소스를 알면 쉽게 디버깅을 할 텐데”라는 체념은 많은 프로그래머의 공통적인 경험입니다만 대개의 공인된 API에 대한 답답함은 소스를 모르는 것에 있지 않고, 제대로 되어 있지 않은 샘플 없는 문서에 있을 것입니다. 이런 경우는 어쩔 수 없이 사용자 포럼의 도움 혹은 검색 엔진을 통한 도움을 받아야 합니다. 모르는 것일수록 샘플을 수집해 사용 예를 구해야 하고, 충분한 샘플 이해 없이 빈약한 문서만으로 시간을 낭비하지 맙시다.
API는 처음부터 완벽하지 않습니다. 그리고 문서도 완벽하지 않습니다. 아직도 수많은 OS의 Undocumented API들이 존재합니다. 어떤 경우든지(문서가 없든지, 문서를 이해 못했든지) API를 자신이 만든 수준으로 이해하지 못한 경우에는 버그가 존재하기 마련입니다.

몇 가지 바른 생활 - 딴짓하는 프로그래머
디버깅은 종합 예술 행위입니다. 전체적이고 섬세한 감각을 소유하지 않으면, 디버깅의 깊이가 그만큼 줄어들게 됩니다. 메모, 프로세스, 파일 I/O, 소켓, UI 등등 체계적인 지식이 없이는 해결되지 않는 경우가 많습니다. 소켓 문제인줄 알고 소켓 관련된 책만 읽다가 나중에는 쓰레드 문제로 판명되는 경우도 있습니다. 대개 디버깅은 의외로 사소한 것을 많이 알고 있을 때 쉽게 해결됩니다. 디버깅을 잘하는 사람은 사소한 것을 꼼꼼히 알고 있는 사람입니다. 그런 면에서 업무 외에 재미로 하는 ‘프로그래밍 딴짓(?)’은 그 사람의 잠재적인 문제해결력을 증강시키는 효과가 있습니다. 이는 결코 측정될 수 없는 능력입니다.
딴짓은 본디 체계가 없는 것이긴 하지만, 배움을 동기로 하는 딴짓은 딴짓 이상의 딴짓입니다. 다음은 필자가 해 보았거나, 쓸만한(?) 딴짓 목록을 적어 놓은 것입니다. 모두 유틸리티와 그것을 사용하는 스크립트입니다. 유명한 유틸리티와 스크립트 언어를 사용하는 것은 프로그래머의 자유도를 높여 줍니다.

【유닉스 계열】
1 접속 후 아무 일도 하지 않은 채 24시간 이상된 사용자 끊는 스크립트 작성해 보기 - w, awk, kill
2 수시로 디스크의 사용량을 확인하여 80% 이상 되었을 때 자동으로 메일 보내기 - df, awk, mail
3 스포츠 신문 만화를 긁어 친구들에게 메일로 보내기 - wget, perl, mail
4 주기적으로 내 특정 디렉토리 전체를 다른 서버로 복사하기 - rsync

【윈도우 계열】
1 조카들이 바꿔 놓는 IE의 시작 페이지를 부팅 후 레지스트리에 원래대로 해 놓기 - VBScript
2 회사내 로컬 IP - 호스트명 테이블 만들어 보기 - nbtstat, perl
3 회사내 공유 폴더 리스트 만들어 보기 - net, perl
* - 뒤는 사용됨 직한 유틸리티입니다

사용자의 눈, 개발자의 눈
여러분이 프로그래머라면 일반 사용자와 눈이 달라져야 합니다. 컴퓨터에서 일어나는 모든 세세한 일까지 호기심을 가지고, 나름대로의 추측을 가지고 있어야 하고, 나중에 문서를 통해 혹은 트레이서 등을 통해 추측을 확인해야 하고, 궁극적으로는 필요한 때에 정확히 재현할 수 있는 코드를 작성할 줄 알아야 합니다.
디버깅은 전문 디버거로 알려진 도구들만의 전유물이 아닙니다. 디버깅은 정상으로 실행되는 프로그램에 대한 이해부터 시작합니다. 응답 시간이 길어지는 프로그램을 잘 살펴보면,
으로 눌러 다른 화면에서 돌아올 때 창이 새로 그려지지 않는 경우가 있습니다. 또 자세히 보면 윈도우의 맨 가장자리 프레임은 항상 그려지게 됩니다. 왜 그럴까요? 일반 사용자의 관점과 달리 프로그래머의 관점에서 보면, 현재 응답을 기다리는 쓰레드가 내부 창을 그리는데 사용되는 것과 같다는 것을 추측할 수 있습니다. 윈도우 맨 가장자리를 다루는 쓰레드는 OS에 소속된 것이지, 응용 프로그램에 소속된 것이 아닐 것 같다는 생각도 해 볼 수 있습니다.
조금 더 얘기하면, 디스플레이 등록정보에는 ‘마우스로 끄는 동안 창 내용 표시’ 같은 기능이 있습니다. 또, MSN Plus에서 제공하는 광고 창 감추기 기능이 있습니다. 이런 것들은 Spy++ 같은 윈도우 메시지 트레이서 기능을 이용해 평소에 눈여겨 두면, 알고 있는 지식과 구현된 기능에 대한 실 예를 통해 폭넓은 이해가 가능합니다. 유닉스의 경우 리눅스의 strace, 솔라리스의 truss, hpux의 tusc 등을 이용해 평소에 inetd 같은 데몬이 어떻게 돌아가는지(option -p) 알아 볼 수 있습니다. 이들은 실행중인 프로그램에 큰 영향을 주지 않으면서, 엿보기 기능을 이용해 구현을 짐작해 보는 것들입니다. API가 아무리 블랙박스처럼 보여도, 평소에 이런 류의 툴을 이용해 시스템 레벨의 입출력을 덤프해 보는 것만으로 API의 내부를 어느 정도 짐작해 볼 수 있습니다. 물론, 리버스 엔지니어링은 많은 소프트웨어에서 금지되어 있다는 사실도 염두에 두면서 들여다보기 바랍니다.

재현 가능성
으례 들을 수 있는 말이지만, 디버깅은 사건을 추적하는 형사가 하는 일과 같습니다. 크게 다른 것은 디버깅은 언제든지 같은 상황을 재현할 수 있는 데 있으며, 형사가 하는 일은 단 한 번의 사건에 국한되어 비슷한 상황을 연출하는 데 그 한계가 있다고 볼 수 있습니다. 우리로서는 참으로 다행이지 않을 수 없습니다. 수만 번 프로세스가 죽고, core dump, watson log 같은 시체만 남는다 해도 윤리적인 가책을 전혀 느끼지 않으니까요. 디버깅을 하는 사람들은 형사처럼 조심스럽게 그 프로세스의 시체들을 디버거를 통해 부검하겠지요.
디버깅을 위한 전제 조건으로 ‘재현 가능성’을 생각해 보겠습니다. 누구한테 디버깅에 대한 조언을 구할 때에도 재현을 하기 위한 방법이 모호하고, 심지어 말을 들어 주는 사람도 증상을 유추하기 어렵다면 별 도움을 받을 수 없을 것입니다. 증상을 제대로 설명하지 않았는데도 답변을 바로 준다면, 그 사람은 아마 여러분의 그룹에서 경외의 대상일 것입니다. 비단, 프로그램뿐만 아니라 전화나 메신저를 통해 컴퓨터의 이상을 호소하는 사람에게 조차 처음 듣는 현상인 경우 그대로 재현할 수 있는 방법에 대해 들어야 올바른 답을 줄 수 있는 것입니다.
또 다른 측면에서 다른 사람에게 설명하기 위해 재현하는 방법을 차근차근 설명하다가 해결책을 아는 경우가 종종 있습니다. 끝까지 설명하지 않았는데 말이죠. 이전까지는 문제의 현상에만 집중한 나머지 처음부터 생각을 하지 않았던 것입니다. 아니, 문제가 다른 부분에 있을 것이라고는 생각하지 않았던 것입니다. 그만큼 어떤 문제가 ‘재현 가능한지’에 대한 것과 ‘어떻게 재현할 수 있는지’에 대한 것은 디버깅을 위한 전제 조건이 됩니다.
개발자와 QA가 분리되어 있는 개발 그룹의 경우, QA의 버그 리포트는 재현 순서에 대한 상세한 설명을 수반하게 됩니다. 또한, 고객 상담실이 운영되어 출시한 프로그램의 사용자 지원이 이뤄질 때도 버그 재현에 대한 상세한 문서가 먼저 선행 조건이 됩니다. ‘재현되지 않는 버그는 고칠 수 없습니다’ - 개발자가 좋아하는 문구입니다. 디버깅을 위한 다음과 같은 공동의 작업 환경이 있다면 훌륭한 팀이 됩니다.

1 소스 버전 컨트롤 : Visual Source Safe, CVS, WinCVS, TortoiseCVS
2 버그 게시판 혹은 회람용 문서 : 배포 버전 번호/테스트 수트, 방법/ 버그 재현 순서/개발자 의견/조치 이력
3 잦은 배포 : 수시로 (2주 이내) 소스 묶음과 설치본을 QA에 넘깁니다

코드 리뷰
디버깅은 아니지만 꼼꼼한 관리자는 개발이 어느 정도 완료된 후 코드 리뷰(code review)를 하자고 합니다. 개발자로서는 참으로 쑥스러운 시간입니다. 한 사람당 한두 시간 정도 들어 발표하는 동안, 지켜보는 모든 사람은 인공지능 컴파일러가 되어 올려지는 모든 소스를 날카롭게 보게 됩니다. 코딩 규칙이나 명료하지 않는 부분, 주석 없는 것이 들키는 시간이지요. 이 컴파일러는 경고가 친절하지 않습니다. 간혹 인간성이 안 좋은 컴파일러로부터 심한 말도 듣게 됩니다. 한 사람 때문에 팀 전체 소스의 신뢰도를 떨어뜨릴 수 있기 때문이죠. 코드 리뷰는 여러 가지 이점이 있지만, 중요한 것은 준비하면서 코드를 다듬게 되며 발표 중에는 개발자조차 간과했던 버그를 발견하는 것입니다.
리턴 값을 확인하지 않고 지나는 경로가 있는지, assert 조건이 있음에도 assert문이 빠져 있다든지, 배열에 대한 boundary 확인이 되지 않은 채 최대 인덱스를 넘어 사용하는 부분이 있다든지, 재현되지 않은 버그까지 발견할 수 있는 이점을 가져다 줍니다. 이런 내용은 다음에 다시 설명 드리겠습니다. 디버깅을 위?코드 리뷰는 다음과 같이 합니다.

【발표하는 경우】
1 구문 컬러링이 되어 있는 에디터를 통해 소스를 보여줍니다(ViewCVS를 사용할 경우 enscript 기능 추가)
2 에디터에서 직접 수정하거나 메모장을 이용해 논의사항을 추후 반영합니다
3 설계 문서를 간단히 준비해 보여줍니다
4 설계상 가장 중요한 구조체/클래스에 대한 헤더를 먼저 소개합니다
5 메인 루프, 즉 함수들을 호출하는 중심이 되는 함수를 먼저 소개합니다
6 설계 문서를 번갈아 가며 구현되어 있는 함수를 보여줍니다.

【듣는 경우】
1 코딩 규칙을 살펴봅니다
2 알고 있는 것과 반대되는 것, 특이한 구현 방식에 대해 질문합니다
3 컴파일러가 그러하듯 질문 내용을 바로 질문해 토의가 일방적이지 않게 합니다
4 추궁하여 당황하게 만들지 말고, 충분히 소개할 수 있는 편안한 자세를 만들어 줍니다

코드 리뷰는 팀 내에서만 이뤄지는 것이 아닙니다. 오픈소스 진영에서는 발표하는 순간부터 코드 리뷰가 이뤄지고 있습니다. 소스에 대한 접근 권한이 있다는 것은 코드리뷰가 진행 중이라는 것이며, 개발자는 다른 사람의 리뷰 결과에 대해 겸손한 피드백을 해주어야 합니다. 코드 리뷰는 디버깅과 튼튼한 코드를 위한 가장 매력적이며 가장 확실한 방법입니다.

남의 코드를 많이 보라
소스는 마치 책과 같아 좋은 소스와 나쁜 소스에 대한 구별법이 없이는 잘못된 습관을 만들 수 있습니다. 디버깅하기 좋은 소스와 코딩하기 좋은 소스는 분명 구별됩니다. 어떤 것이 과연 디버깅하기 좋으냐에 대한 생각은 다를 수 있지만, 아무 소스나 보면서 그 소스에 대한 깔려 있는 생각을 읽을 수 없다면 습관이 잘못 들어 디버깅은 더 어려워질 수 있습니다. 그럴지라도 다른 사람의 소스를 많이 보십시오. 소스포지나 코드구루 등은 공개된 소스를 얻을 수 있는 좋은 사이트입니다. 특히, 팀으로 작업하는 프로젝트의 소스를 보십시오. 그 팀에서 코딩 가이드를 제시하고 있다면 더더욱 소스에 대한 질이 높아집니다.
나중에 다른 API를 사용해 연동할 일이 생긴다면, 그 소스를 볼 수 있다는 것은 디버깅에 큰 도움을 주게 됩니다. 한 페이지의 매뉴얼보다는 한 페이지의 소스가 더 도움이 되는 법이지요. 소스에 대한 경험이 많을수록 소스에서 느끼는 부드러움과 안정감, 위태로움, 불안함에 대한 감각이 자라나게 됩니다. 다른 사람의 소스를 읽는다는 것은 그 사람과 대화하는 것입니다. 그렇게 되면 코딩 스타일이라는 굴레를 벗어나게 되는 것입니다. 감상하는 법을 아는 사람만이 예술 작품세계에 대한 평을 할 수 있는 것입니다. 감상하는 법을 아는 사람이 설계 패턴과 설계 철학을 읽을 수 있습니다. 그리고 자연스럽게 자신의 소스에 대한 섬세한 손질을 할 수 있습니다.

표준 문서 숙지 - 네트워크 프로그래밍 디버깅
네트워크 프로그래밍은 필수 요소로 프로토콜이라는 전송 규약이 수반됩니다. 디버깅의 1차 목표는 프로토콜에서 정의한 패킷들이 필드 규격에 맞게 전송되고 있는지를 확인하는 것입니다. 그리고 다음으로는 필드의 내용이 프로토콜에 맞게 제 값을 가지고 다니는지를 확인하는 것입니다. 이를 위해 수반되는 것은 TCP/IP에 대한 명세를 확실히 하는 것입니다. TCP/IP 기반 네트워크 프로그래밍에서는 필수적으로 MAC 어드레스에 대한 개념과 IP 어드레스, 네트워크 어드레스, 브로드캐스팅 어드레스, 넷마스크, 디폴트 루트에 대한 개념을 책을 보며 익히되 패킷을 캡처해 가면서 공부하는 것을 권합니다. 더미 허브(스위칭 허브가 아닙니다)를 사용하면 네트워크에서 돌아다니는 모든 패킷을 다 읽을 수 있으므로, 패킷 캡쳐 툴을 사용해 가만히 들여다 보는 것만으로 책안의 내용이 살아나게 됩니다. 이런 툴 하나 정도는 꼼꼼히 옵션 찾아가며 익힐 것을 권합니다. tcpdump를 권하며, 윈도우에서는 같은 류의 windump가 있습니다. 둘의 옵션이 비슷하므로 하나를 익히면 다른 것도 쉽게 사용할 수 있습니다. 그 외에 GUI로 제공하는 많은 툴이 있으므로 찾아서 익히기 바랍니다(검색어 packet capture, sniffing).
인터넷 필수 기본 프로토콜에 대한 것은 문서를 익히는 것에서 패킷 캡처를 통한 확인, 그리고 많은 커맨드 라인 방식의 프로토콜(SMTP, HTTP, POP, NNTP, FTP)에 대해서는 telnet을 이용한 테스트까지 완전히 자기 것으로 만들어야 합니다. 물론 샘플을 구해 클라이언트를 만들어 본다면 더 없이 훌륭합니다. 네트워크 프로그래밍 개발자들이여, 문서를 읽어 용어를 아는 정도는 비개발자들도 하는 것입니다. 하물며 개발자는 패킷 캡처까지 하여 프로토콜 확인은 할 줄 알아야 합니다.

깊은 프로그래밍을 위한 첫 발걸음
소스를 많이 보면서 구체적인 점을 이야기하지 않았습니다만, 전반적으로 프로그래밍과 디버깅을 시작하는 사람들이 가져야 할 모습을 다루어 보았습니다. 시작부터 튼튼한 사람은 없습니다. 처음에는 버그를 잡았지만, 소 뒷걸음에 쥐를 잡은 듯이 넘어가는 일이 많습니다. 프로그래밍과 디버깅을 따로 뗄 수는 없는 것입니다. ‘코딩 끝 디버깅 시작’이라는 말같이 디버깅을 염두에 두지 않은 코딩은 그 깊이가 얕을 수밖에 없습니다. 프로그래밍이라는 작업은 만만치 않지만, 설계부터 코딩, 디버깅이 끝난 프로그램이 잘 돌아가는 것을 보는 것은 예술가적인 안목에서 참 흐뭇한 일입니다. 짧은 연재이지만, 뒤 이어지는 연재들을 같이 나누며 깊은 프로그래밍을 위한 발걸음을 차근차근 내딛어 봅시다.

프로그래머는 그 고집만큼 습관이 고착되어 있습니다. 여러 습관 중에서 가장 강조하고 싶은 것은 다음과 같습니다.

◆ 코딩 규칙 준수
◆ 로그 API 먼저 작성하기
◆ Assert문 이용하기
◆ 자원 관리 철학 갖기
◆ 다중 if문 갖지 않기

디버깅에 관한 코딩 습관
코딩 규칙을 준수합시다

개발이 시작되면 대개의 경우 팀으로 프로그래밍을 하게 되며, 설계가 끝나고 코딩에 들어가기 전에 항상 코딩 규칙을 만들게 됩니다. 혹은 회사에 전부터 정해진 코딩 규칙이 있다면 그것을 개발에 적용하게 됩니다. 이런 코딩 규칙은 통일을 기하기 위해 만들어집니다. 파일 명명법, 함수․변수 명명법, 괄호의 위치, 파일 주석, 함수 주석, 선언 주석, 들여쓰기 방법 등에 대한 것을 기술하며, 훈련이 잘 되어 있는 개발팀이라면 이런 코딩 방법에 대한 통일을 이루게 됩니다.

코딩 규칙은 미래의 자신과 개발 중인 다른 사람과의 협업을 위해서는 반드시 지켜야만 하는 것입니다. 이런 코딩 규칙과 디버깅과의 상관 관계는 일부 코딩 규칙이 버그 발생을 예방하기 위해 만드는 것이 있다는 것입니다. 필자가 권하는 것은 컴파일러다운 관용의 자세를 가지라는 것입니다. 이 말은 아무렇게나 작성하라는 것이 아니라 코딩 규칙이 프로젝트가 바뀔 때마다 변할지라도 자신을 능동적으로 맞춰가라는 것입니다. 코딩 규칙 중에서 많은 프로젝트에서 사용하는 두 가지를 소개하겠습니다.

◆ 단일 실행문을 갖는 if, while, for문이라 할지라도 중괄호(‘{’, ‘}’)를 기입한다(<리스트 1>).
<리스트 1>과 같은 규칙은 처음 작성할 때는 문제가 되지 않지만, if 안의 블럭에 실행문을 하나 더 추가할 일이 생길 경우 중괄호가 없는 예에서는 간혹 실수하여 if의 참, 거짓에 상관없이 다음에 실행되는 문장으로 인식될 수 있는 경우가 발생합니다. 특히 printf(“Check %s:%d”, __FILE__, __LINE__);과 같이 중간 중간 현재 진행되는 위치를 출력하려고 중요한 위치(함수 시작, 조건 판단, 함수 종료 등의 위치)에 마구 복사해 넣다 보면 <리스트 1>과 같은 경우가 흔히 발생합니다. 다른 예를 들어 보겠습니다.

<리스트 1> 코딩 규칙 1
규칙 준수 예 :
if( pTemp != NULL ) {
*pTemp = ‘x’;
}

규칙 미준수 예 :
if( pTemp != NULL )
*pTemp = ‘x’;


◆ ++, -- 연산자는 함수 호출 인자 내에 쓰지 않고 호출 앞 혹은 뒤에 따로 쓴다(<리스트 2>).
-- 위치에 따라 ‘사용 후 감소’ 또는 ‘감소 후 사용’이라는 모호성과 함수 호출시 인자로 넘어갈 값을 결정하는 순서(<리스트 2>에서는 두 번째 인자 --count와 세 번째 인자 score[count])가 섞이면 상당히 골치 아픈 일이 발생합니다. <리스트 2>에서는 count가 printf에 넘어간 뒤 --가 수행될지, --가 먼저 되고 printf에 넘어갈 지에 대해 생각을 합니다. 또한 printf 함수에 넣기 위해 --count를 먼저 할지, score[count]를 먼저 계산할 지에 따라 score 배열의 인덱스가 달라지는 문제가 발생합니다. --에 대한 것은 책을 찾아 명확하다고 할지라도 함수에 넘길 인자의 정확한 값을 구하기 위한 순서는 컴파일러마다 다를 수 있습니다. 모든 컴파일러가 같다고 할지라도 가독성을 떨어뜨리므로 좋은 코딩이라고 볼 수 없습니다.

<리스트 2> 코딩 규칙 2
--count;
printf( “Last index %d, Last value: %d”, count, score[count] );

규칙 미준수 예 :
printf( “Last Index: %d, Last value: %d”, --count, score[count] );

간단히 코딩 규칙의 예 중에서 버그 방지를 위한 것들로 자주 사용되는 것을 살펴봤습니다. 디버깅과 상관없이 코딩 규칙에 대해 말하자면, 코딩 규칙을 따르지 않고 자신만의 습관을 사용하는 것은 프로다운 모습이 아닙니다. 오히려 전문가는 프로그램 설계, 즉 구조에 중점을 두어야 합니다. 자신만의 코딩 규칙보다는 팀의 규칙을 따르는 것이 도움을 주고받을 때에도 시간을 단축할 수 있습니다. 필자가 속한 그룹에서는 들여쓰기와 괄호 위치, 선언 위치 등에 대한 것을 정해 놓고, code beautifier(GNU indent)를 사용하여 표준을 따르도록 고쳐주는 옵션을 정한 뒤 팀원들이 공유하여 코딩 규칙 일부에 대해 자동화합니다.

Log API 만들기
사실 printf를 디버거라고 부르기에는 적당하지 않습니다. 디버깅을 위한 값을 추적하는 방법에 불과하기 때문이지요. 여기서는 printf로 대표되는 ‘실행 중 값 출력’에 대해 말하고자 합니다. 프로그램을 시작하는 모든 사람이 오류가 발생하면 관심 있는 변수의 추이를 보고 싶어하고, 그런 변수가 실행 중 어떻게 변하는지를 살펴보는 것으로 처음 디버깅을 경험하게 됩니다. 이 방법이 정형화된 것이 바로 다단계 로그입니다. 잘 되어 있는 프로그램은 로그의 단계를 조절할 수 있는 기능(최소한 남길지 말지에 대한 기능)이 있어서 사용자가 종류별, 단계별로 로그를 원하는 파일에 심지어 원하는 포맷으로 남길 수 있습니다. 프로그래밍을 할 때 처음 구현을 위해 남기는 로그를 printf로 남기다가 나중에는 모조리 지웁니다. 왜냐하면 주로 이런 모습이기 때문입니다.

!!!! temp file name: gHie88009.dat
-------------- CHECK 1
---------------CHECK 2
client: 192.168.10.1 2890 8 17:30:13

아무 의미 없어 보이지만 실제 구현되기 전까지 만든 사람에게는 중요한 정보가 됩니다. 구현되고 나면 당연히 주석 처리가 되거나 삭제되는 코드입니다. 체계적인 로그 관리는 참으로 중요합니다. 나중에 문제가 생길 경우, 심지어 고객에게 배포된 것에 문제가 생길 경우에는 로그를 보내주고, 그 로그를 받아오면 좋은 경우가 많기 때문입니다. 앞과 같은 로그를 남기는 데 그냥 줄 수 있습니까? 애만 태우게 됩니다. 앞과 같은 로그 대신 프로젝트가 사용할 로그 API를 이용합니다. 이때 최상위 레벨, 즉 가장 자세한 상황으로 로그를 남기는 옵션일 때만 남기도록 함수를 하나 만들어 앞 로그를 보기 좋게 수정하여 코드에 넣는다면, 나중에 로그를 자세히 남길 필요가 있을 때(팀장의 협박하에 또는 고객지원을 위해)에도 많은 수고를 덜 수 있게 됩니다.
윈도우 프로그래머들은 TRACE와 TRACEn(n은 string을 제외한 인자의 개수)으로 대표되는 디버그 모드 추적기가 있습니다. 이 경우에 있어서도 될 수 있으면 팀에서 로그 포맷을 정하고, 개발 후에도 삭제하지 않고 유용한 정보로 사용하는 것이 좋습니다.

시공 감리 assert
‘assert를 잘 쓰면 기본은 뗐다’고 칭찬해 줄 정도입니다. 잘 쓴다는 얘기는 남발한다는 것이 아니라 필요한 부분에는 꼭 쓰고, 쓰지 말아야 할 곳에는 안 쓴 코드를 말합니다. assert는 중요하지만 많은 실전 경험 없이는 기술이 완성되는 것이 아니므로 몇 가지 예를 들어 설명하겠습니다. assert 사용이야말로 버그를 줄일 수 있는 가장 중요한 습관입니다. assert는 #include 과 같은 헤더를 포함해야 쓸 수 있습니다. 사용 방법은 단지 assert( <평가식> );과 같은데, 함수 호출이 있으면 <평가식>은 0이 아닌 값, 즉 참 값을 가져야만 합니다.

/* #define NDEBUG */
#include

#include

int main()
{
int i = 0;
assert( i );
}

앞과 같은 코드는 반드시 assert문에서 오류를 발생시킵니다. 오류에는 파일 이름과 행, 그리고 어떤 값이 오류를 일으켰는지에 대한 정보를 보여주게 됩니다. 다시 앞 코드를 컴파일할 때 #define NDEBUG의 주석을 푼 뒤 실행하면 오류가 나지 않음을 알 수 있습求? NDEBUG라는 매크로가 선언되어 있으면 모든 assert문은 빈 명령어가 되는 것입니다. assert.h는 ANSI C 표준에 들어 있으므로 ANSI C를 지원하는 라이브러리가 있다면 어떤 플랫폼에서도 사용할 수 있을 것입니다.

◆ assert는 내부 설계에 대하여 변수가 원하는 내용을 가지고 있는지 확인하는 데 사용한다(<리스트 3>).
<리스트 3>을 보면 두 함수가 등장하는데, process 함수를 SMTP의 ‘서버 메시지 같은 꼴’의 응답 메시지를 처리하는 데 사용하는 함수라 생각해 봅시다. SMTP 결과 메시지의 간단한 모양은 다음과 같습니다.

220 mail.test.com ESMTP Ready

<리스트 3> SMTP 적용 예제
const char szLastMessage[1024];
void saveLastMessage ( const char * szMessage )
{
strncpy( szLastMessage, szMessage, sizeof( szLastMessage ) );
/* sizeof는 배열 szLastMessage의 최대 크기 */
}

/* szLine은 <세 자리 숫자><공백><서버 메시지> */
void process( const char * szLine )
{
int code;
code = atoi( szLine );
saveLastMessage( szLine + 4 );
/* 이하 생략 */
}

SMTP에서는 세 자리의 응답 코드만 가지고 대부분 처리되지만, 사람이 읽을 수 있는 메시지는 한 칸의 공백을 두고 쓰게 되어 있습니다. 이 정도 규약이 있다고 가정합니다. process 함수는 내부에 saveLastMessage 함수를 부르고 있으며, saveLastMessage는 그 인자인 szMessage를 서버의 응답을 처리하는 데 맨 앞에 있는 함수가 아니라는 것을 알고 있습니다. 즉, saveLastMessage는 process라는 선처리 함수 뒤에서 작용하는 함수라는 설계가 반영된 것입니다. saveLastMessage는 적법한 메시지일 경우에 불리운다고 가정합니다. 이런 상황을 두고 적당한 assert 위치와 assert 내용을 살펴봅니다. 일단 함수에 들어오는 szLine, szMessage에 대한 상황을 생각해 봅니다.

saveLastMessage :
ꊱ szMessage는 널 포인터가 아니어야 한다.
ꊲ szMessage는 실제 내용이 있어야 한다.

process :
ꊱ szLine은 널 포인터가 아니어야 한다.
ꊲ szLine[0], szLine[1], szLine[2]는 숫자이어야 한다.
ꊳ szLine[3]은 공백이어야 한다.
ꊴ szLine + 4 위치에 메시지가 들어 있어야 한다.

<리스트 4> assert의 적용 예
const char szLastMessage[1024];
void saveLastMessage ( const char * szMessage )
{
assert( szMessage ); /* null pointer가 아님 */
assert( szMessage[0] ); /* 실제 내용이 있음 */
strncpy( szLastMessage, szMessage, sizeof( szLastMessage ) );
/* sizeof는 배열 szLastMessage의 최대 크기 */
}

/* szLine은 <세 자리 숫자><공백><서버 메시지> */
void process( const char * szLine )
{
int code;
assert( szLine ); /* null pointer 아님 */
assert( isdigit(szLine[0]) && isdigit(szLine[1]) && isdigit(szLine[2]) ); /* 세 자리 숫자 */
assert( szLine[3] == ‘ ’ ); /* 공백 */
assert( szLine[4] ); /* 실제 메시지 내용 있음 */
code = atoi( szLine );
saveLastMessage( szLine + 4 );
/* 이하 생략 */
}

앞과 같은 사항을 반영한 assert가 들어간 코드는 <리스트 4>와 같습니다. assert와 관련해 szLine과 szMessage의 가장 큰 차이는 설계상 szLine은 처음으로 서버 즉 외부의 데이터를 받는 부분이고, szMessage는 한번 걸러진 변수라는 것입니다. szLine이 준수해야 하는 규칙은 서버에서 응답을 이상하게 준다면 충분히 깨질 수 있는 상황이며, 그런 상황이 벌어진다면 saveLastMessage는 적법한 경우가 아니므로 불리우지 않는 것이 설계의 내용입니다. 그러므로 적법한 상황에서 불리우는 szMessage는 알 수 없는 오류가 있지 않는 한 널 포인터일리 없고 내용이 없을리도 없습니다. 따라서 설계상 서버의 오작동과 같이 외부 입력에 대한 것을 처리할 수 있는 것은 assert로 하는 것이 아니라 if문으로 처리하여 적절한 오류 처리 루틴을 따라야 합니다. 외부 데이터의 변화와 상관없는 assert를 정리하면 다음과 같습니다.

saveLastMessage :
ꊱ szMessage는 널 포인터가 아니어야 한다.
ꊲ szMessage는 실제 내용이 있어야 한다(szMessage 길이가 0으로 saveLastMessage 함수가 호출되지는 않는다).

process :
ꊱ szLine은 널 포인터가 아니어야 한다.
ꊲ szLine에 코드를 비롯한 메시지가 들어 있어야 한다(szLine 길이가 0으로 process 함수가 호출되지는 않는다).

<리스트 5> 설계와 구현에 대한 assert의 용법
void process( const char * szLine )
{
int code;
assert( szLine ); /* null pointer 아님 */
assert( szLine[0] );
/* 서버의 응답의 적합성을 파악하기 위해서는 길이가 적어도 4바이트가 되어야
배열 참조 인덱스가 유효하므로 먼저 길이 조사를 한다. */
if( strlen( szLine ) < 5 ||
! (isdigit(szLine[0]) && isdigit(szLine[1]) && isdigit(szLine[2]) ) ||
szLine[3] != ' ' )
{
/* 오류 로그 */
return;
}
code = atoi( szLine );
saveLastMessage( szLine + 4 );
/* 이하 생략 */
}

<리스트 5>는 설계와 구현에 대한 assert의 용법에 대해 알아 본 것입니다. 함수의 모든 인자에 대한 것은 함수 첫 부분에서 assert를 해줘야 합니다. 클래스 멤버 함수에 관해서는 인자뿐 아니라 사용하는 멤버 변수에 대한 것도 포함됩니다. 다음은 대표적인 assert문이 사용되는 방법입니다.

ꊱ 포인터의 경우 널인지 여부
ꊲ 일반 변수의 설계상의 범위 혹은 정확한 값 준수 여부
ꊳ 다중 if, else if, switch 등의 복잡한 판단 후 처리에 대한 결과 확인



assert를 사용하지 말아야 할 대표적인 곳은 다음과 같습니다.

ꊱ 외부 데이터 입력 변수
ꊲ 메모리 할당 결과
ꊳ 파일 열기 결과

이번에는 assert를 프로그래밍 습관보다는 디버깅에 사용하는 방법을 생각해 봅시다. 디버깅할 때 심지어는 멤버 함수의 경우 this 포인터가 널이 아닌지 확인해야 하는 경우도 있습니다. 버그가 발견되었을 때 문제가 발생한 곳을 중심으로 의심가는 곳에 assert를 심어 넣습니다. 이 경우에는 assert가 오류를 내고 프로그램이 멈추어야 되므로, 앞에서 사용하지 말아야 할 상황까지 일부러 넣어가면서 프로그램을 임시로 지저분하게 가져가야 합니다. 사용하지 말아야 할 곳에 넣은 assert는 나중에 다시 빼야 하므로 아예 들여쓰기를 하지 않고 넣는 것도 한 방법일 것입니다. 이 방법은 if로 조건을 벗어나는 것에 대한 로그를 남기는 것보다 확실합니다.
정리하면 assert는 설계의 흐름을 제대로 구현하고 있는가에 대한 감리 역할을 하고 있는 것입니다. 많은 경우 아주 가끔씩 일어나는 에러의 경우에도 assert를 충분히 해주었다면 쉽게 잡을 수 있는 경우가 있습니다.

자원 미제거에 대한 방어
자원 할당 또는 제거라 함은 메모리 할당, 파일 open, socket accept, close 등을 말합니다. 시스템 자원(메모리 포함)의 새는 것(resource leakage)에 대한 추적은 디버깅의 어떤 언어든 끝없는 주제일 것입니다. 메모리 및 시스템 자원에 대한 것은 다음과 같은 설계 철학을 공유하지 않으면 체계적이 될 수 없습니다. 물론 메모리 새는 것과 자원 새는 것들에 대해 추적할 수 있는 도구 혹은 추적 가능하게 해주는 라이브러리를 사용하는 방법이 있습니다만, 소스 수준에서 올바를 습관을 기르면 디버깅에 도움이 될 수 있기에 정리해 봅니다.

ꊱ 자원 할당과 제거를 동일한 계층에서 일어나도록 한다.
ꊲ 자원 할당과 제거를 논리적으로 시작 모듈과 끝 모듈에 맞추어 일어나도록 한다.

둘은 서로 반대되는 얘기입니다만 일종의 패턴이라고 생각하면 됩니다. 첫 번째, 자원 할당과 제거가 동일한 계층에서 일어난다는 것은 같은 모듈 내에 생성 소멸을 두라는 이야기입니다. 다른 말로 하면, 가능하면 자원을 할당한 함수에서 해제하라는 것입니다. 또는 그것이 불가능할 경우 같은 클래스 안에서 혹은 동일한 파일 내에서 할당, 제거할 수 있는 설계 방법을 취하는 것입니다. 두 번째는 생성되는 곳과 소멸되는 곳을 특정한 두 개 정도의 함수, 클래스 혹은 파일로 모으라는 것입니다. 만약 쓰레드 등을 써서 소켓을 accept하는 곳과 close하는 곳이 분리돼야만 한다면, 여러 곳에서 close하지 말고 모아두라는 것입니다. 즉, 프로세스의 시작과 끝이 다른 경우에는 자원이 생성되고 소멸되는 위치가 되도록 모여 있도록 하라는 것입니다.

이런 패턴을 따르지 않을 경우 할당 제거에 대한 명확한 문서화가 되어 있어야 합니다. 되도록 그런 문서를 만들지 않아도 알기 쉽게 앞 패턴을 따르는 것이 좋습니다. 다음은 같은 함수 내에서 제거하는 모습입니다. 나쁜 예는 process 함수 내의 주석 처리한 부분에서 pConfig 객체가 소멸되는 것입니다.

void process( Config * pConfig )
{
/*... 처리 ... */
/* delete pConfig; */
}

void run()
{
Config * pConfig = new Config("/etc/test.cfg"); /* pConfig가 널인지 확인하는 코드 생략 */
process( pConfig );
delete pConfig;
}

◆ 자원 할당 전에 변수가 비어 있는지 확인해야 한다.
◆ 자원 제거 후에는 변수를 초기 값으로 환원시켜야 한다.

자원 할당 전에 할당한 자원을 받을 변수에 어떤 의미있는 내용이 있는지 확인해야 합니다. 또한 자원 제거 후에는 반드시 그 변수를 초기 값으로 환원시켜서 다음에 해제되었는지를 확실히 해야 합니다.

/* 메모리 할당, 해제 전에 확인 */
if( pBuffer )
{
free( pBuffer );
}
pBuffer = (char *) malloc( BUFFER_SIZE );
/* 처리 */
if( pBuffer )
{
free( pBuffer );
pBuffer = NULL;
}

/* 파일 닫은 후에 초기화 */
if( fd >= 0 )
{
close( fd );
fd = -1;
}

좀더 상세하게 살펴봅시다. 다음의 예에서 g_pBuffer와 fd 변수는 초기 값으로 각각 NULL, -1을 가지고 있다고 합시다.

/* --- MEMORY --- */
if( !g_pBuffer ) {
g_pBuffer = (char &) malloc( BUFFER_SIZE );
}
/* --- FILE --- */
if( fd >= 0 ) {
if( !close(fd) ) {
printf(“close error”);
}
}
fd = open( “/tmp/log.txt”, O_RDONLY );

이제 자원 할당 문제와 assert를 이용한 디버깅을 알아봅시다. 앞의 예에서 g_pBuffer 값이 논리적으로 프로그램 흐름상 g_pBuffer는 초기화되어 있어야 한다면, 또한 open하기 전에 fd 값이 초기 값(-1)을 유지할 수밖에 없다는 것이 확실하다면, 즉 모든 실행 경로에서 if 조건들이 결코 참이 될 수 없다면 if 위에 assert를 넣어 다음과 같이 만들어 자신의 논리를 굳히는 프로그래밍을 할 수 있어야 합니다. 문법 오류를 컴파일러가 잡아내듯 논리 오류를 잡아내는 데 사용됩니다. 실행 도중 논리적인 설계 외의 행위가 발생한다면 그것은 디버깅감입니다.

assert( g_pBuffer );
assert( fd < 0 );

◆ 불필요하게 파일 기술자를 두 번 이상 close하지 않는다.
뭔가 확실히 해두려고 두 번 이상 파일 기술자를 닫는 경우가 있습니다. 이 경우 두 번째의 close는 당연히 닫힌 파일에 대한 close이므로 오류를 일으키며, 일반적으로 프로그래머는 close의 오류 확인을 하지 않는 경우가 많습니다. 물론 앞의 습관이 제대로 들어 close 후에 -1(혹은 Handle의 경우 NULL)로 초기화하고, close할 때는 반드시 0보다 크거나 같은지에 대해 확인을 하겠지만 그것은 안전을 위한 방법이며, 그것보다 먼저 점검할 것은 생각할 수 있는 모든 경로에서 정확히 close를 한번만 하는지 확인하는 것입니다. close 전에 assert를 넣어 두 번 close를 하는지 점검해 보는 것도 좋습니다.

다중 if문 피하기
다중 if를 최대한 줄일 수 있도록 만들면 그만큼 가독성을 높게 합니다. <리스트 5>와 <리스트 6>을 비교하면 처음 만나는 if문을 바꿔 씀으로써 이중 if문을 단일 if로 바꾸었습니다. <리스트 5>와 같은 코드는 나름대로 정상적인 흐름을 머릿속에 생각하고 정상적인 것만 처리하는 데 집중하여 나온 것입니다. 습관을 바꾸면 비정상적인 것을 먼저 판단하되 비정상적인 것이 잘 일어나지 않는 상황입니다.

게다가 로그를 남겨야 하는 등 많은 일을 처리해야 한다면 assert문을 넣어 그 함수 안에 들어오면 반드시 오류가 나도록 처리해 두고, 그 아래에 계속 생각의 흐름을 진행시키는 방향으로 코드를 작성하는 것이 좋습니다. 이런 습관은 일석이조의 효과를 거두게 됩니다. 코드의 가독성을 높이고, 구현을 미루어 놓아도 나중에 까먹지 않게 되지요. 다음과 같은 방법으로 간단히 처리하고 나중에 assert에 걸릴 때 적절한 코드를 넣어도 정상적인 것을 우선 작성하는 데 큰 어려움이 없을 것입니다.

if( 0 >= (size=recv( s, buf, 1024, 0 )) ) {
assert( 0 && “TODO: You should process error”);
}

<리스트 5> if문 사용 예 1
int check( s )
{
char buf[1024];
int size = 0;
if( 0 < (size=recv( s, buf, 1024, 0 )) ) {
buf[size] = ‘\0’;
if( ‘2’ == buf[0] ) {
return 0;
}
return 1;
}
printf(“Socket closed.”);
return -1;
}

<리스트 6> if문 사용 예 2
int check( s )
{
char buf[1024];
int size = 0;
if( 0 >= (size=recv( s, buf, 1024, 0 )) ) {
printf(“Socket closed.”);
return -1;
}
buf[size] = ‘\0’;
if( '2' == buf[0] ) {
return 0;
}
return 1;
}

빌드 과정 파악하기
지난 호에서 필자는 ‘언어와 환경’이라는 주제로 언어 명세와 라이브러리를 분리할 줄 알아야 한다고 했습니다. 이번에는 언어와 라이브러리를 조작하는, 흔히 말하는 컴파일러를 분석해 보겠습니다. 범용성을 가진 언어로서 C/C++는 그만큼 많은 제작사가 있으며, 많은 컴파일러를 만들어 내놓았습니다. 그 중에는 상용도 있으며(비주얼 C++, 볼랜드 C++ 빌더, 솔라리스 cc 등), 상용에 못지 않은 공개용(gcc/g++, djgpp, mingw)도 있고, 상용이었다가 이제는 공개용(터보 C)으로 된 것도 있습니다. 이들은 일부는 순수한 컴파일러만을 가지고 있으며, 일부는 어셈블러와 링커까지 포함된 것도 있습니다. 또 어떤 것은 통합 환경을 제시하는 것도 있고, 어떤 것은 커맨드라인 실행만을 지원합니다.

이런 구분을 잘 이해하려면 어떤 언어든지 다음을 이해해야 합니다. 디버깅이 어려운 것은 이런 구분 없이 오류를 해결하려고 하기 때문입니다. 한 번의 빌드 중에는 다음과 같은 일이 발생합니다. 간단한 명령 하나를 내리는 것 같지만 소스는 전처리 과정을 거쳐 컴파일러에 들어가고, 컴파일되어 나온 어셈블 코드 혹은 메타 언어 코드는 어셈블러를 통해 목적 파일이 생기고, 여러 목적 파일들을 합하여 하나의 실행 파일을 만들게 됩니다. 이 과정에서 중간에 에러 메시지가 나오게 됩니다. 그 에러 메시지가 다음 중 어떤 과정에서 일어나는지를 이해하는 것이 디버깅을 돕는 빠른 길입니다.

ꊱ 전처리기(pre-processor)
ꊲ 컴파일러(compiler)
ꊳ 어셈블러(assembler)
ꊴ 링커(linker)

이런 구분을 몇 가지 에러 메시지를 통해 이해해 봅시다. 전처리기와 링커의 경우를 살펴보겠습니다. 처음에는 숨겨 있기 때문에 바로 알 수 없는 경우가 많거든요.



<그림 1> 빌드 순서 개요




전처리기
전처리기, 즉 컴파일러에 들어가기 전에 처리하는 대표적인 것은 다음과 같습니다.

ꊱ #ifdef/#else
ꊲ #define
ꊳ #include

컴파일러는 사실 앞의 구문을 이해하지 못합니다. 앞의 내용을 바탕으로 컴파일러에 소스 중 일부만을 넘긴다거나 치환하여 넘긴다거나 다른 소스를 포함시켜 넘기는 것입니다. 컴파일러가 보는 것은 앞에서 제외한 모든 것이라 보면 되겠습니다. 그 중 유의할 것은 ꊱ typedef, ꊲ #pragma이지요. typdef와 #define을 많이 비교하는데, 사실은 처리하는 위치가 다르다는 것을 기억해 두기 바랍니다. pragma는 표준화된 것이 아니므로 컴파일러마다 다르다는 것을 이해해야 합니다. 따라서 컴파일러마다 공통적인 것이 아니라면 #pragma 앞뒤로 컴파일러 특유의 매크로가 정의되었는지 확인하는 #ifdef가 오게 됩니다. 다음은 C++ 코드입니다.

/* filename: a.cpp */
int main()
{
printf("Hello, world\n");
return 0;
}

앞의 코드를 컴파일하면 제대로 되지 않습니다. “implicit declaration of function ‘int printf(...)’”라는 오류를 내면서 멈추게 됩니다. printf를 암묵적으로 선언하여 사용했기 때문이죠. 즉, 정확한 선언 없이 사용했다는 것입니다. 이것이 C라면 암묵적인 선언도 무사하겠지만, C++라면 반드시 선언해야만 함수를 사용할 수 있으므로 컴파일이 더 이상 진행되지 않습니다. 선언을 제대로 하는 것은 어떻게 보면 컴파일러 문제겠지만, 우리는 여기에서 #include 라는 헤더 파일이 빠져 있음을 알 수 있습니다. stdio.h를 열어 보면, 어딘가 printf 함수의 원형이 선언되어 있음을 알 수 있을 것입니다. 이것은 전처리되어 앞에서 헤더가 포함되어 함수를 선언해 주는 것이 빠져 있기 때문에 생긴 것입니다. 물론 #include하지 않고 해당 printf를 앞에 그대로 복사해 놓아도 상관없습니다. 그것은 전처리기를 통하지 않고 컴파일러 안에서 처리한 것이죠.


전처리의 특성 파악
전처리의 특성을 알면 유용할 때가 많습니다. 컴파일 옵션 중에 미리 선언한 값을 넘기는 경우가 있습니다. 그 값에 따라 #ifdef를 만나면 소스의 특정 부분을 선택적으로 컴파일할 수 있게 되지요. 디버깅을 하다 보면, 헤더 파일을 열었을 때 두 가지 선택 중 어떤 것이 선택되었을까 궁금할 때가 있게 됩니다. 이런 경우를 대비해서라도 컴파일 전에 들어가는, 즉 전처리된 소스를 한번 보기로 합시다.
앞 코드를 #include 를 넣어 제대로 돌아가도록 한 뒤 전처리기만 통과하여 나온 소스를 보기로 합시다. 유닉스용 g++의 경우 -E 옵션을 넣어 주면(g++ -E a.cpp) 컴파일러에 들어가기 전의 코드를 구할 수 있습니다.

비주얼 C++ 6.0의 경우 도스 명령 창을 실행하여 해당 소스가 있는 곳으로 이동한 뒤 cl /E a.cpp라고 명령을 내려주면 됩니다. cl 명령이 실행되지 않을 경우 경로가 잡혀 있지 않기 때문입니다. cl은 Visual Studio\VC98\Bin 디렉토리에 있습니다. cl이 바로 gcc/g++과 같은 역할을 하는 컴파일러인 것이지요. 정확히 cl과 gcc/g++ 등은 컴파일러를 부르는 구동기입니다.

링커
링커는 컴파일되어 나온 목적 파일들을 서로 묶어 주는 역할을 하는 것입니다. 흔히 이런 오류를 많이 보게 됩니다. 다음은 앞의 소스에서 main을 test로 이름을 바꾼 것입니다. 즉, 전체 프로젝트에 main 함수가 없는 상황이죠.

<리스트 7> a.cpp
#include
int test()
{
printf("h");
return 0;
}

g++ a.cpp -o a
/usr/lib/crt1.o: In function ‘_start’:
/usr/lib/crt1.o(.text+0x18): undefined reference to ‘main’
collect2: ld returned 1 exit status

VC++
Compiling...
a.cpp
Linking...
LIBCD.lib(crt0.obj) : error LNK2001: unresolved external symbol _main
Debug/a.exe : fatal error LNK1120: 1 unresolved externals
Error executing link.exe.

<리스트 7>을 보면 컴파일러가 다름에도 불구하고 undefined reference, undefined external symbol이라는 비슷한 오류가 발생했습니다. 그리고 g++에서는 crt1.o, VC에서는 crt0.obj이라는 것을 보게 됩니다. 마지막으로 g++에서는 collect2(ld)가, VC에서는 link.exe가 오류를 내는 것을 확인할 수 있습니다. 흔히 처음 이런 오류를 만났을 때에는 고민하다가 main 함수가 없어서 발생하는 것을 알게 되지요. 좀더 살펴보면 오류를 낸 것은 링커입니다.

오류의 위치는 main이라는 reference 혹은 external symbol을 찾지 못했다는 것이고 공교롭게도 crt 뭔가를 처리하다가 발생했습니다. 즉, 컴파일은 모두 성공적으로 끝났으며, 그 다음 단계인 링킹에서 오류가 발생한 것이지요. VC의 경우 main 대신 _main을 찾다가 생긴 오류로 나오는데 이것은 윈도우에서 흔히 사용하는 오브젝트 내의 심볼 표현 방법으로 C언어에서 만든 함수 이름 앞에 “_”을 붙이기 때문입니다. 메시지의 정확한 의미는 C++ 컴파일러가 crt라는 런타임 오브젝트를 먼저 처리하며, 그 오브젝트 안에는 다른 프로그램 어딘가로부터 main 함수를 필요하도록 만들어져 있습니다. 따라서 그것을 연결시켜야 하는데 전체 프로젝트 안에 main이라는 오브제트가 없는데서 발생한 것입니다.

비슷한 오류는 특정 라이브러리를 추가로 넣어줘야 하는데 빠졌을 경우 발생합니다. 윈도우 프로그램을 작성할 때는 소켓 라이브러리를 따로 프로젝트 환경 설정에 넣어야 하는 경우가 있고, 유닉스의 경우에도 수학 관련 함수를 사용할 때나 쓰레드 관련 프로그램을 할 때 항상 추가적인 라이브러리를 지시해 주어야 하는 경우가 있습니다. 모두 undefined reference, undefined external symbol 오류입니다. 이것은 컴파일러(어셈블러 포함)를 통과한 소스가 최종적으로 생성되어 나온 오브젝트는 내부에 포함된 함수와 외부로부터 필요한 함수가 어딘가에 기록되어 있다는 뜻입니다. 물론 변수도 그와 비슷하게 기록되어 있습니다. 이런 것을 알아보는 것이 부속 프로그램으로 따라 다니게 됩니다.

디버깅을 잘하는 것에 대하여 지난 호에 덧붙이자면, 언어와 환경(라이브러리, OS, 프로토콜)을 구별할 줄 알아야하며, 현재 오류가 난 부분이 전처리에서 난 것인지 컴파일에서 난 것인지 링커에서 난 것인지 구별할 줄 알아야합니다.

빌드 과정 정리
전처리기를 통하여 나온 소스는 모든 변수와 함수는 사용 전에 선언되어 있어야 하며, 컴파일러 명세에 있는 문법구문이 아닌 것은 모두 typedef되어야 합니다. 따라서 전처리를 통과하여 나온 것에는 주석이 모두 제거되고, 모든 #define문이 치환되며, include된 것들은 통째로 하나의 파일로 되어 전달됩니다. 이 소스를 볼 수 있다면 여러분이 소스를 깊게 이해하고, 디버깅하는 데 도움이 될 것입니다.
컴파일러를 통해 나온(정확히는 컴파일러와 어셈블러를 거쳐 나온) 목적 파일은 C 런타임 라이브러리(crt)와 결합하여 실행 파일을 만들게 됩니다. 따라서 모든 목적 파일과 crt에는 undefined symbol된 것이 어딘가에는 존재해야만 합니다. 만일 존재하지 않는다면 그것은 DLL(혹은 유닉스의 .so, .sl 등)과 같은 동적 연결 라이브러리 파일명을 적어 두고, 로더에 의해 실행 도중에 바인드되도록 만들어집니다.

진정한 전문가의 자세
지금까지의 설명을 토대로 생각하면 디버깅 과정은 빌드 과정을 깊이 이해하는 것처럼 보입니다. 사실 디버깅은 빌드 과정에 대한 이해는 기본으로 하고, 빌드 이후에 있는 어셈블리어를 통한 디버깅이나 System call trace를 통한 프로그램의 흐름을 상상하며 문제의 위치를 찾아내는 것을 주로 지칭합니다. 중요한 것은 시행착오를 남길 때마다 표면적인 문제가 해결된 것에 만족하지 않고, 내부 동작을 좀더 음미해가면서 문제를 해결하려는 태도입니다.

따라서 디버깅은 고도의 좋은 의미의 해킹과도 관계 있는 것입니다. 더불어 좋은 코딩 습관은 고집스런 뭔가를 고수하는 것보다 보다 유연하게 팀 작업을 돕기 위한 모습으로 자신의 활동 범위를 넓히는 것이 좋습니다. 진정한 전문가는 코딩 스타일보다 아키텍처에 관한 얘기를 하는 것입니다. 아무쪼록 깊은 이해를 위해 좋은 습관과 컴파일러와 그 주위를 둘러싼 유틸리티와 친해지길 바라며, 세부 옵션도 수시로 확인하고 정교하게 도구들을 사용할 수 있는 여러분이 되길 바랍니다.

어떻게 하면 버그를 빨리 발견할 수 있을 것인가? 이것은 모든 프로그래머의 공통적인 관심사입니다. 많은 언어들은 이것에 부응하기 위해서 문법적인 장치들을 고안해 넣었습니다. 문법에 그런 장치를 넣었다는 것은 컴파일러에 의해 사용자의 의도 중에 잘못될 소지가 있는 것을 지적할 수 있는 이점이 있습니다. C/C++가 어려운 이유는 변수 타입이 다양하며, 심지어 각 타입에 signed/unsigned가 추가되고, 포인터에는 const이냐 아니냐에 따라 생각해야 할 많은 성질들이 들어가기 때문입니다. 현존하는 언어 중에서 변수에 대한 가장 섬세(?)한 조절 기능이 있다고 해도 과언이 아닙니다. 어쨌든 버그를 컴파일 타임으로 끌어 올려 발견할 수 있도록 하는 것이 문법이 가지는 목적 중 하나이며, 그런 문법의 의도를 충분히 이해하고 언어를 사용할 수 있다면 컴파일러가 단순히 컴파일만을 목적으로 하는 것이 아니라 디버깅 툴(?)로도 사용될 수 있음을 알 수 있습니다. 세 가지 예를 들어 프로그래밍의 깊이를 조금 깊게 느껴보는 시간을 갖기로 하겠습니다.

의도를 나타내는 ‘const’
함수는 부르는 자와 불리는 자의 주고받는 행위입니다. 뭘 주고 뭘 받을지에 대한 것을 프로그래머가 의도한 대로 반영하게 되는데, 몇 가지 함수 call을 살펴보면서 이해해 봅시다.

// 함수 선언과 포인터의 의미
1 int strlen( const char * str );
- str이 가리키는 내용을 건드리지 말라.
2 void strncpy( char * buf, const char * source, int max );
- source가 가리키는 내용을 건드리지 말라, buf가 가리키는 내용은 바꾸어도 좋다.
3 int strcmp( const char * s1, const char * s2 );
- s1, s2가 가리키는 것을 건드리지 말라.
4 FILE * fopen( const char * filename, const char * mode );
- fopen에서 넘어 오는 포인터가 가리키는 것을 맘대로 바꾸어도 좋다.
5 int fclose( FILE * fstr );
- fstr가 가리키는 내용은 바꾸어도 좋다.

앞에 잘 알려진 표준 C 라이브러리의 문자열 처리와 파일 개폐 함수를 나열하였습니다. 선언된 내용 중 포인터에 대한 것을 말로 서술하여 표현해 보았는데, 자세히 읽어보고 원래 함수가 하는 일과 비교하여 이해하기 바랍니다. const가 꾸미는 내용은 변수가 아니라 변수가 가리키는 것의 속성입니다.

// const 선언의 예
6 const char * buf;
7 char const * buf;
8 char * const buf;
9 const char * const buf; 또는 char const * const buf;

1번의 의미는 “ *buf = ‘a’; ”와 같은 방법으로 가리키는 내용을 바꾸는 일로는 사용될 수 없다는 것입니다. 2번의 의미는 정확히 1번과 같습니다. 그것은 const가 “*” 앞에 있어 “*”를 수식하는 것이며, 변수가 가리키는 내용이 상수라는 것입니다.
3번의 의미는 1, 2번과 다릅니다. 즉 가리키는 내용을 바꾸는, “ *buf = ‘a’; ”는 허용되지만 “ buf++ ” 같이 포인터 값 자체를 바꾸는 것은 허용되지 않습니다. 4번의 예는 앞 두 제약을 모두 가지고 있는 것이 됩니다. 이런 생각을 염두에 두고 앞 코드의 1번 문자열 길이를 구하는 함수 strlen이 내부적으로 할 수 있는 예를 생각해 봅시다. 그것은 포인터가 가리키는 값, *str 값이 ‘\0’인지 비교하면서 str++를 수행하며 원하는 결과를 구하게 될 것입니다.
함수를 호출할 때 변수의 포인터를 원하는 함수가 있다고 가정합시다. 만약 그 함수가 const 포인터(const *)를 원한다면 호출 이후 그 내용이 전혀 변하지 않을 것이 확실합니다. 하지만 일반 포인터를 인자로 원한다면 설사 그 함수가 내용을 고치지 않는다고 문서에 씌어 있어도 사실상 보장할 수 없게 됩니다. 이것이 “프로그래머의 의도”입니다. 변하느냐 변하지 않느냐, API 상에 데이터 변형에 대한 의도를 나타내어 버그가 생길 수 있는 소지를 막는 것이 컴파일러가 디버깅을 도와 줄 수 있는 방법입니다. 다음은 const가 있어야 함에도 뺀 함수에 대해 컴파일러는 다음과 같은 오류를 반환하는 예입니다.

#include

void welcome( char * username ){
printf(“Welcome! %s\n”, username );
}
int main() {
const char * name = “hojin”;
welcome( name );
return 0;
}
결과 - compile warning:
warning: passing arg 1 of `welcome’ discards qualifiers from pointer target type

welcome은 분명 username이 가리키는 것을 사용만 할 뿐 바꾸는 것이 없습니다. 그런데도 선언이 const *가 아니므로, welcome 함수를 사용하기 위해 const를 무시하겠다는 내용입니다. 이것에 대한 해결책으로 name에 대한 const까지 없앤다구요? (char * name = “hojin”;) 제발 그러지 마세요. 꼬이는 지름길입니다.

의도를 나타내는 ‘const’ 멤버 함수
C++의 const 사용에 대한 예를 들기 전에 C++의 멤버 함수에 대해 살펴보기로 할까요. 모든 멤버 함수는 하나의 인자가 숨겨 있다고 생각해야 합니다. 바로 그 클래스에 대한 this 포인터입니다. 따라서 아무 것도 인자를 받지 않는 함수라 할지라도, 항상 this 포인터라는 최소 한 개의 인자를 받는다는 사실입니다. this 포인터가 모든 인자보다 먼저 전달된다고 생각합시다. 그러면, 이 this 포인터는 숨겨 있으므로 “const” 수식을 할 수 없지 않겠느냐고 생각할 수 있겠지만 그렇지 않습니다. 우선 C++의 const 사용에 대한 예를 들어 보겠습니다(<리스트 1>).

<리스트 1> C++ const 멤버 함수
#include
#include

class ZConfig {
protected:
char _strFileName[256];

public:
ZConfig(){};
~ZConfig(){};

void setFileName( const char * strFileName ) {
strncpy( _strFileName, strFileName, sizeof _strFileName );
}
const char * getFileName() const {
return _strFileName;
}
};

int main() {
ZConfig conf;
conf.setFileName("test.cfg");
cout << conf.getFileName() << endl;
return 0;
}

<리스트 1>은 getFileName 함수 뒤에 붙은 const의 예를 보여줍니다. 이것이 this 포인터에 대한 const 여부를 나타낸다고 생각하면 됩니다. 이것은 C로 해석하면 다음과 같은 형태로 이해할 수 있습니다. 컴파일될 코드는 아니지만 살펴보면,

setFileName( ZConfig * this, const char * strFileName ) {
strncpy( this->_strFileName, strFileName, sizeof _strFileName );
}

const char * getFileName( const ZConfig * this ) {
return this->_strFileName;
}

getFileName의 선언이 “const char * getFileName() const”가 아닌 “const char * getFileName()”으로 바꾸고 다음의 예에서 사용된다고 생각해 봅시다.

// getFileName의 const 여부에 따른 컴파일

class ZConfig {
..중략..
const char * getFileName() {
return _strFileName;
}
};

void run( const ZConfig * pConfig ) {
cout << pConfig->getFileName() << endl;
}

int main() {
ZConfig conf;
conf.setFileName(“test.cfg”);
run( & conf );
return 0;
}
결과 - Error:
In function `void run (const ZConfig *)’:
passing `const ZConfig’ as `this’ argument of `const char *ZConfig::getFileName ()’ discards qualifiers

C++에서는 const에 대한 처리를 warning으로 다루지 않고 error로 다루는 강력한 검사 기능을 제공하는데, 앞에 기록된 error는 “const char * getFileName()”와 같이 맨 뒤의 const를 제거한 형태로 컴파일할 경우에 발생합니다. 자, 여기서 개발자의 의도를 생각해 봅시다. 설계를 한다면 setFileName은 분명히 클래스 멤버 변수를 변경하는 의도가 있음을 알 수 있습니다. 따라서 이 함수 뒤에는 const가 따라오지 않을 것이라는 것이 분명합니다. 또한 run 함수의 의도는 ZConfig 객체가 읽기 전용으로만 사용될 것임을 예상할 수 있습니다. 그리고 그 안에서 사용된 getFileName도 멤버 변수를 읽는 함수라 생각되어 사용한 것입니다.
그런데 ZConfig에서 getFileName 함수 선언시 맨 뒤에 const를 넣어 두지 않으면, run 함수와 같이 객체의 const 포인터를 사용하여 멤버 함수를 호출하는 경우 const 포인터가 내용을 변화시키는 의도를 나타내므로 컴파일러는 오류를 내는 것입니다. 다음을 잘 생각해 보면 getFileName을 C 형태로 표현한 것이며, this 포인터가 const가 제거되는 상황임을 알 수 있습니다.

const char * getFileName( ZConfig * this ) {
return this->_strFileName;
}
void run( const ZConfig * pConfig ) {
cout << getFileName( pConfig ) << endl;
}

내부적으로는 this의 const 여부를 나타내는 것으로 해석하면, C++의 멤버 함수에 대한 것을 C의 표현법으로 해석할 수 있으므로 C++에서 const 함수라는 것이 왜 필요하게 되었는지 생각하는 데 도움이 될 것입니다. 이런 의도를 알게 되었다면, 다음부터는 컴파일러에게 객체의 변형에 대한 의도를 충분히 반영하여 장차 생길 수 있는 오류를 컴파일 타임 때 잡아내도록 노력합시다.

의도를 나타내는 ‘static’ 함수
C에서 static 함수의 표면적 의미는 ‘외부에서 불러 쓸 수 없는 그 파일 내부에서만의 함수’로 알려져 있습니다. 도대체 이게 왜 필요할까요? 이런 문법이 갖는 의도를 알지 못하는 이상 어떤 함수에 static을 줄지 말지를 심각하게 고민하지 않고, static은 전혀 사용하지 않는 편리한(?) 프로그램을 작성하게 됩니다. 잠시 링커를 생각해 봅시다. 링커가 하는 일은 오브젝트 파일들을 엮어서 실행 파일을 만드는데 있습니다. 엮는다는 의미는 외부에서 사용할 것이므로 어디엔가 함수 이름을 대비해 두고, 전 오브젝트에서 이름이 충돌하지 않게 관리된다는 추가적인 행동을 요구합니다. 안에서만 쓸 것이라 생각한 것들도 말이죠. 다른 말로는 이름 공간을 더럽힌다고 말합니다. 사용자 의도를 잘 해석하는 컴파일러는 <리스트 2>와 같은 경고를 보내줍니다.

VC++의 경고를 해석하면 “local function으로 만들었는데, 어느 곳에서도 사용하지 않으니 제거하겠다”는 뜻입니다. 사용자의 의도를 정확히 해석한 것이죠. 만약 함수 앞에 static이 없었다면 이런 경고를 내지 않을 것입니다. “다른 곳에서 사용하는가 보다”라고 해석하는 것이죠. static 함수라고 명시해 놓으면 나중에 프로그램을 고치다가 더 이상 쓸모없는 함수가 될 경우 즉시 컴파일러로부터 경고가 나올 것입니다. 그것에 따라 그 함수를 제거하면 프로그램 흐름에 대한 가독성을 높여 줄 것입니다. 이 static과 관련하여 헤더 파일 작성법도 약간의 영향을 받습니다.

◆ static이 아닌 것은 extern이 생략된 것이며, 이런 함수들은 모두 외부에서 사용되는 것이므로 헤더 파일에 선언을 한다.

◆ static인 것들은 .c 파일 전방에 선언한다. 혹은 사용하는 함수는 항상 뒤에 작성한다.

의도를 나타내는 클래스의 ‘static’ 멤버 함수
클래스의 static 멤버 함수와 일반 멤버 함수의 가장 큰 차이는 앞에서 언급한 this 포인터가 암묵적으로 전달되는지의 여부에 따라 다릅니다. C++ 책에도 나오듯이 static 멤버 함수는 일반 멤버 변수/함수에 접근하지 못하며, 단지 static 멤버 변수/함수만을 접근 가능하다고 알고 있을 것입니다. 이것은 static 멤버 함수는 this 포인터가 넘어가지 않는 함수이기 때문입니다. 따라서 this 포인터가 있어야만 하는 일, 앞과 같이 일반 멤버 변수/함수를 사용하는 호출에 대해 컴파일 오류가 생길 것입니다.

쓰레드를 만들 때 쓰레드의 입구 함수에 대한 형을 맞추는 것을 주의해야 합니다. 윈도우의 경우 CreateThread 함수를 사용하며, 유닉스의 Posix 쓰레드의 경우 pthread_create 함수를 사용합니다. 이 함수들은 인자에 입구 함수를 요구하며 각각 다음과 같은 형태의 함수 포인터여야 합니다.

Windows 쓰레드: DWORD func(void *);
Posix 쓰레드: void * func( void * );

문제는 다음과 같이 쓰레드 입구 함수에 클래스의 멤버 함수를 넣고 싶을 때 발생합니다. <리스트 3>은 posix 쓰레드를 사용한 것으로, XThread 클래스를 만드는 예제라고 가정하고 그 클래스에 두 개의 멤버 함수를 넣어 봅시다. 하나는 static 멤버 함수이며, 다른 하나는 일반적인 함수입니다.

<리스트 3>에서 사용된 pthread_create의 세 번째 인자로 들어가는 쓰레드 입구 함수는 앞의 코드와 같은 C 함수만 가능하다고 생각할 수 있습니다. 하지만 XThread에서 static 멤버로 선언된 thread_entrance1은 해당 요구사항을 만족하게 됩니다. 바로 this 포인터가 넘어가지 않기 때문에 가능한 것이죠. 직접 컴파일해 확인해 보기 바랍니다. 윈도우의 경우도 비슷하게 테스트해 볼 수 있습니다. 자, 이렇게 만들었을 때의 이점은 어떻게든 this 포인터를 받을 수 있다면(void *로 받는 param에 넘겨서) XThread의 protected 변수까지 접근 가능하다는데 있습니다.

const, static이 C++에서 많이 확장된 것을 확인해 보았습니다. 이런 내용은 오류 메시지를 잘 분석해 얻어질 수 있는 개념들입니다. C++ 명세에서 나왔다기보다는 C용 라이브러리와 같이 써야만 하는 환경해서 생각하다 보면 에러 메시지를 통해 얻을 수 있는 것입니다. 디버깅을 하면서 발생하는 컴파일 에러 메시지는 어떤 예제보다도 더 머릿속에 쏙 들어오는 문제집이자 참고서입니다.

헤더 파일 열어 보기
헤더 파일을 자주 열어 봐야 합니다. 즉, 유닉스 계열에서는 /usr/include 디렉토리, 비주얼 C++ 6.0에서는 VC98/include 디렉토리에 들어 있는 파일들의 내용을 이해한다는 것입니다. 이 헤더 파일은 사실 프로그래밍 초기에는 별로 보고 싶지 않은 내용으로 가득 차 있습니다. 그런 마음으로 프로그래밍을 하다가도 가끔씩 열어보곤 합니다. 하지만 여전히 신기한 문법을 사용하는 것 같아 보입니다. 헤더 파일이 복잡하게 보이는 것은 이식성을 고려해 만들기 때문입니다.

① WIN32와 MAC을 고려한 선언 부분을 발견할 수 있습니다.
② 32비트와 64비트에 따라 파일을 다루는 함수 등에서 파일 크기 등을 고려하여 달라지는 부분이 있습니다.
③ C와 C++ 동시에 사용되는 헤더 파일이 대부분이고, C용 헤더 파일의 경우에는 extern “C” 등이 항상 보입니다.
④ CPU의 endian에 따라 달라지는 부분이 있습니다.
⑤ ANSI C 이전의 컴파일러를 고려해 선언되는 인자를 없애고 선언해야 하는 경우도 있습니다.
⑥ 표준화(ANSI, X/Open, POSIX, BSD 등)에 따라 선언이 안되는 경우도 있습니다.
⑦쓰레드에 따라 달라지는 부분이 있습니다.
⑧예외(Exception)를 지원하는 않는 컴파일러를 고려해 달라지는 부분이 있습니다.
⑨디버깅(NDEBUG, DEBUG)에 따라 달라지는 부분이 있습니다.
⑩CPU 등의 아키텍처에 따라 달라지는 부분이 있습니다.

이렇게 많은 경우의 수를 하나의 헤더로 해결해야 하는 것이지요. 디버깅을 잘하려면 때때로 헤더 파일들을 뒤져가면서 보는 것이 좋습니다. 또 헤더 파일들을 잘 보면 앞과 같은 복잡한 상황에 대처하는 방법도 익히게 되며, 알지 못했던 상황도 고려하게 되는 경우가 많습니다. 디버깅을 위해서라도 헤더 파일 읽는 것을 소홀히 하지 마세요. 또, 한 가지 방법만 고려해 프로그램을 작성하다 보면 나중에 미처 생각지 못한 부분들이 발생하게 됩니다. 따라서 평소에 어떤 이식성 문제들이 있는지를 염두에 두면 디버깅할 때 입체적인 접근을 할 수 있습니다.

메모리에 대한 이해
메모리에 대한 이해는 코드를 작성할 때 데이터가 어떤 부분에 들어가는지에 대한 이해도입니다. 많이 접하는 네 가지의 예를 들어 보면 다음과 같습니다.
◆ 데이터
◆ static 변수(BSS)
◆ 자동 변수(stack)
◆ 힙

이중에서 데이터 영역과 BSS라 알려진 static 데이터 영역은 컴파일된 코드 내에 그 영역이 설정됩니다. 간단히 설명하면 데이터들은 static 변수에 들어가는 초기 값과 변수들이 저장되는 곳이며, BSS는 초기 값이 없는 static 변수가 들어가는 것이라고 보면 됩니다. 초기 값이 없고 변수 길이 정보만 잡힙니다(검색어 : data bss stack 세 개를 동시에 주면 자세한 내용을 더 알 수 있습니다). 다음 코드를 통해 익혀 봅시다(유닉스에서는 nm과 objdump를 통해서 확인해 볼 수 있습니다).

#include
const char * B;
const char * D = “R”;
int main() {
static int B2;
char * S = (char *) malloc( 32 );
S[0] = ‘H’;
return 0;
}

BSS 영역 : B, B2
데이터 영역 : D, “R”
스택 영역 : S
힙 영역 : S가 가리키는 곳, ‘H”가 저장되는 곳

앞 내용에 대한 것은 익숙해질 때까지 symbol 확인 유틸리티를 사용하고, disassemble 코드를 분석해 가면서 이해해야 합니다.

힙에서 흔히 발생하는 오류 형태
힙과 스택을 사용하는 데 생길만한 오류는 거의 바운더리 체크(boundary check)입니다. 먼저 GNU에서 제공하는 GNU C 라이브러리가 구현된 것의 개념만으로 설명하자면, malloc이 호출될 때 C 라이브러리는 요구한 크기의 연속된 메모리 공간(세그먼트라고 합시다)을 반환하여 줍니다. 그것은 다음과 같이 표현됩니다.

malloc에 의해 요청되는 크기에 8바이트 정도 추가 정보(A,B)가 먼저 기록되고 P 값이 리턴되어 넘어 옵니다. 커널에 요청하는 최초 메모리(A)가 넘어오는 것이 아니군요! 그리고 free로 해제할 때도 마찬가지로 P 값을 받아 8바이트를 뺀 위치에서 현재 해재돼야 할 길이를 구해 삭제 표시를 하게 되는 것입니다. 이런 이유로 free에서는 해제될 크기를 인자로 받지 않습니다. C++의 new, delete에서도 배열을 할당한 경우 delete[]를 호출할 때 원 배열의 크기를 넣지 않는 것도 같은 구현 방법으로 되어 있으며, 어떤 C++의 STL 구현에서 string에 대한 것도 내부 정보를 앞과 같은 방법으로 구현하는 것을 본 적이 있습니다. 중요한 것은 힙에서 버퍼 오버플로우가 일어날 때, 다음 블럭의 크기 정보를 망가뜨려 해제하거나 추가 할당할 때, 빈 공간에 대해 추정할 때 오동작하게 됩니다. 만약 오동작이 일어날 경우 이런 힙의 구조를 이해하면 추적하는 데 도움이 될 것입니다. new, delete도 결국에는 malloc, free를 이용하는 것입니다. 즉, 메모리를 할당받은 후 할당된 메모리에서 생성자나 소멸자가 불리는 것으로 이해하면 됩니다. 참고로 유닉스 계열에서 커널에 힙을 요청할 때는 내부적으로 brk 함수를 사용합니다.

스택에서 흔히 발생하는 오류 형태
스택은 흔히 번지 값이 감소하며 할당된다고 알려져 있습니다. 다음의 예와 같은 경우 <그림 2>와 같은 형태의 주소 배열을 갖습니다.

oid func() {
int x;
int y;
char buf[32];
}
buf가 나중에 선언되었지만 그 영향은 x, y에게 미친다는 것입니다. 또 스택이 사용되는 곳은 함수 호출과 관련되어 있습니다. 함수 호출은 calling convention이나 CPU 아키텍처마다 다릅니다. calling convention에 따라 함수에 넘어간 변수를 불린 함수가 해제하는 것이 있기도 하고, 부른 쪽에서 책임지고 해제하는 경우도 있습니다. 어떤 calling convention이나 CPU 아키텍처에 따라 함수 호출 인자의 몇 개까지는 레지스터에 넘기는 것도 있습니다.

함수가 호출될 때 일어나는 일은 돌아올 리턴 주소가 먼저 저장되며, 다음으로 프레임 포인터라는 것이 저장됩니다. 프레임은 각 함수의 단위로 생각하면 되는데, 그 함수 내에서는 모든 자동 변수들이 같은 베이스 포인터를 가지고 베이스 포인터로부터 얼마나 떨어져 있느냐로 표현됩니다. 스택 포인터는 늘 변하기 때문에 함수에 들어오자마자 프레임 포인터를 현재의 스택 포인터 값으로 바꿉니다. 예를 들어 <그림 2>를 살펴보면, frame pointer = 136이라 하고, frame pointer - 0을 x, frame pointer - 4를 y frame pointer - 36을 buf라 사용하는 것입니다. 따라서 하나의 함수가 시작하면 이전 함수의 프레임 포인터를 저장해 두어야 돌아가기 전에 복원됩니다(x86에서는 어셈블리어 leave 명령도 참고하세요).

스택 버퍼 오버플로우 공격이라 일컬어지는 것의 원리도 여기에 있습니다. buf에 쓰여지는 내용을 buf의 범위를 벗어난 리턴 어드레스까지 덮어 쓰게 만들고, 원래 호출한 함수로 돌아가는 것이 아니라 buf 내용 중간으로 점프하게 만들어 원하는 일을 하게 만드는 공격법이지요. 이런 이유 때문에 buffer 범위를 확인하지 않는 함수는 절대 사용 금지입니다(두 함수가 대표적입니다 : strcpy, sprintf). 정리하면 스택이 잘못될 경우 다음과 같이 영향을 미칠 수 있습니다.

① 갑자기 어떤 변수 내용이 바뀌었을 때, 그보다 아래 선언된 변수에서 영향을 받았을 가능성
② call stack이 이상하게 바뀌었을 경우에는 버퍼에 쓴 내용이 저장된 프레임 버퍼와 리턴 주소까지 영향을 준다.

원리를 알면 왜 그렇게 사용하지 말아야 하는가를 쉽게 이해할 수 있습니다. 원리를 모른 채 경고 메시지 제거나 프로그래밍 가이드를 따른다면 재미가 덜하겠죠? 디버깅은 경험한 만큼 언어와 컴파일러를 구현 원리까지 생각하도록 유도합니다.

C++의 함수 이름 꾸미기
C++에서 가장 초반에 공부하는 것 중의 하나는 함수 인자가 다르면, 같은 이름의 함수를 여러 개 만들 수 있다는 함수 오버로딩에 대한 것입니다. 그리고 C++에서 C에 대한 코드를 사용하려면 항상 extern “C” 지시자를 선언문에 넣어 두어야 제대로 사용되는 것을 배우게 됩니다. <리스트 4>를 보면 a1.c의 코드를 b1.cpp에서 사용하려면, a1.c에서 만든 함수에 extern “C”를 선언하는 것을 볼 수 있습니다.

이렇게 하는 이유는 C에 의해 생성되는 심볼과 C++에 의해 생성되는 심볼이 다르기 때문입니다. C에 의해 생기는 것은 함수명 그대로 심볼이 생기는 반면, C++에 의해 생기는 것은 함수명 뒤에 함수 인자에 따른 장식 문자들이 들어갑니다. <리스트 4>와 <리스트 5>의 a1.c, a2.cpp가 컴파일된 다음에 생기는 오브젝트의 심볼들을 살펴보면(<리스트 6>) C++에 의해 생성된 테스트에 대한 것은 “__Fii”라는 문자들이 붙게 됩니다. 아마 integer, integer라는 뜻이 맨 뒤에 붙었나 봅니다.

이런 문자들은 함수 인자에 따라 혹은 클래스 멤버에 따라 다양하게 준비되어 있습니다(윈도우에서는 OBJ 파일들에 대해 dumpbin /symbols라는 명령과 옵션으로 확인해 볼 수 있습니다). 이렇게 예상한 함수 뒤에 붙는 것을 함수 이름 꾸미기(function name mangling 혹은 function name decoration이라 하는데 적절한 번역이 없습니다)라고 합니다. 이렇게 하는 주된 이유는 함수 중첩 선언(function overloading)을 하기 위해 사용되는 오브젝트 파일 상에서의 기법입니다. 여기서 잘 생각해 볼 것은 C로 됐든 C++로 됐든 오브젝트 파일이 생기면 내부적으로는 사실상 같이 취급되는 것인데, 마찬가지로 포트란이나 파스칼도 오브젝트가 되면 링커에 의해 결합되는 것만을 남게 됩니다.

이런 원리를 알면 링커가 혼동하지 않도록 소스에서 오브젝트에 포함되는 심볼들의 이름을 일치시키는 것이 프로그래머가 해야 할 일입니다. 바로 extern “C”라는 선언문 앞 지시자의 역할이 함수 이름 꾸미기를 하지 말 것을 지시하는 것이지요.

nm a1.o
00000000 t gcc2_compiled.
U printf
00000000 T test

nm a2.o
00000000 t gcc2_compiled.
U printf
00000000 T test__Fii

재미로 한 가지 해 봅시다. 앞의 소스를 보면 b2.c라는 소스를 볼 수 있습니다만, 이것은 C++로 되어 있는 코드를 C에서 사용한 예를 나타낸 것입니다. 대개의 경우와 반대가 되겠지만, C에서 C++의 함수 이름 꾸미는 규칙에 대해 알고 일부러 없는 함수를 선언하여 링크되도록 유도한다고 해서 안 될 것이 없습니다. 다만 b2.c 같은 예제는 철저하게 재미로 만든 소스입니다. 결코 실전에서는 사용되어서는 안 됩니다. 왜냐하면 C++의 함수 이름 꾸미기 규칙은 컴파일러마다 완벽(?)하게 다르기 때문에 이름 규칙을 알고 소스를 작성하는 것은 무의미한 일입니다. b2.c 코드 또한 gcc에서 만드는 이름 규칙을 생각해서 만든 것이므로 다른 환경에서는 되지 않을 것입니다. 따라서 서로 다른 컴파일러에 의해 만들어진 C++ 코드는 이름 꾸미는 방식이 다른 것으로 인해 쉽게 같이 쓸 수 없는 단점이 있고, C++ 라이브러리는 컴파일러마다 독립적으로 존재한다는 것을 나중에 확인해 볼 수 있을 것입니다.

디버깅=프로그래밍 수련 과정
이번 호에서는 서로 관련 없는 사항이긴 하나 세 가지 정도를 들어 디버깅을 하면서 내부에서 돌아가는 원리를 알게 되는 일에 중점을 두어 설명하였습니다. 디버깅은 해킹과 같은 고도의 입체적인 접근과 연결되어 있는 개발 행위(?)입니다. 따라서 디버깅은 단순한 문제 해결 관점보다는 좀더 테스트 코드를 만들어 보게 하고, 언어와 환경에 대한 깊은 이해를 돕는 프로그래밍 수련회와 같은 것이라고나 할까요?

세 번에 걸쳐 디버깅에 대한 감각이 잡히려는 사람들을 대상으로 어떤 관점을 가져야 될지 주제를 골라 나열하였습니다만, 아는 것을 말로 표현하는 것이 쉽지만은 않다는 것을 느꼈습니다. 필자에게 따로 연락해도 도움을 드리겠지만, 되도록 지적인 공유를 위해 필자가 자주 이용하는 http://bbs.kldp.org/에 질문하면 여러 사람으로부터 다양한 도움을 얻을 수 있을 것입니다. 개발자들이 모두 디버깅에 자신을 갖고, 명랑한 코딩을 하기를 상상해 보면서 이만 연재를 마칩니다.

반응형