노무현 대통령 배너


2006. 7. 20. 13:56

[본문스크랩] 디버깅 다시 보기

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

'프로그래밍 > GNU' 카테고리의 다른 글

GCC Online Documents  (0) 2008.12.15
디버깅 팁  (0) 2006.08.10
리눅스에서 즐길 수 있는 프로그래밍 언어  (0) 2006.03.31
[링크] X 윈도우 프로그래밍 기초  (0) 2006.03.30
[본문스크랩] objdump...  (0) 2006.03.28