<출처> https://blog.naver.com/tipsware/221267359134

1. 개발자와 버그(Bug, 의미상의 오류)
프로그램을 하다 보면 본인의 의지와는 상관없이 버그에 시달리기 마련입니다. 경험이 많아지고 실력이 좋아지면 버그에 시달릴 확률이 낮아지지만 경험이 별로 없는 초보자들에게는 버그가 프로그램을 포기하게 만드는 제일 무서운 적일 것입니다.

버그는 개발자의 개발 스타일에도 영향을 많이 받기 때문에 좋은 스타일로 프로그램을 하는 개발자에게 스타일을 전수받는 것이 가장 좋습니다. 하지만 지금 당장 그런 개발자에게 배울 수 있는 상황이 아니라면 버그를 예방하거나 버그를 찾는 방법에 대해서 먼저 배워야 합니다.


2. 버그가 발생하는 코드
아래의 예제는 포인터를 제대로 이해하지 못한 초보자들이 많이 실수하는 코드입니다. 즉, 아래의 예제 코드는 컴파일하면 경고가 발생하긴 하지만 오류 없이 컴파일이 잘 됩니다.

#include <stdio.h> int main() { char *p; *p = 10; printf("%d\n", *p); return 0; }

하지만 이 프로그램을 실행하면 아래와 같이 디버그 오류가 발생하면서 프로그램이 종료되어 버립니다.

이 예제에서 디버그 오류가 발생하는 이유는 이 예제에서 사용된 포인터 p가 아직 제대로 된 주소를 가지고 있지 않은데 그 주소에 가서 10을 대입하려고 하기 때문입니다. 즉, 포인터 p가 다른 변수 또는 메모리의 주소를 저장한 상태가 아니라서 p가 가지고 있는 주소는 쓰레기 값(초기화되지 않은 임의의 값)이 들어 있다는 뜻입니다.

하지만 위와 같은 경우에는 비교적 쉽게 버그를 잡을 수 있습니다. 왜냐하면 아래와 같이 경고가 발생하기 때문입니다. 그래서 소스를 컴파일할 때 출력되는 경고는 주의 깊게 보는 것이 좋습니다.

warning C4700: 초기화되지 않은 'p' 지역 변수를 사용했습니다.



3. 경고에 대처하기 위해 초기화를 해보겠습니다.
초보자의 입장에서는 경고 메시지에 대응하기 위해 아래와 같이 초기화를 진행할 것입니다. 왜냐하면 보통 C 언어 문법을 배우면 포인터는 NULL로 초기화하라고 되어 있기 때문입니다. 
(여기서 NULL은 (void *) 0이기 때문에 숫자 0을 대입하는 것과 같은 의미입니다.)

#include <stdio.h> int main() { char *p = NULL; // 포인터를 0 번지로 초기화 한다. *p = 10; printf("%d\n", *p); return 0; }

이제 컴파일하면 경고가 발생하지 않습니다. 하지만 실행해보면 이번에는 디버그 오류가 아닌 그냥 일반 응용 프로그램 오류가 발생합니다.

스티커 이미지

4. 오류를 해결해 봅시다.
이렇게 오류가 발생하는 이유는 포인터 p에 저장된 주소가 쓰레기 값이든 NULL이 든 둘 다 제대로 된 주소가 아니기 때문입니다. 따라서 아래와 같이 제대로 된 주소를 저장해야지 오류가 발생하지 않습니다.

#include <stdio.h> int main() { char temp; char *p = &temp; // temp 변수의 주소를 포인터 p에 저장한다. *p = 10; printf("%d\n", *p); return 0; }

하지만 프로그램을 하다 보면 사용할 주소가 대입되는 시점이 미뤄지는 경우도 있기 때문에 위와 같이 제대로 된 주소(temp 변수의 주소)를 대입할 수 없는 경우도 있습니다. 따라서 포인터 p에 주소가 저장되는 시점이 미뤄질 수도 있다는 점을 고려하면 오히려 아래와 같이 코드를 구성하는 것이 더 안전한 방법입니다. 즉, 포인터 p를 NULL 값으로 초기화하고 p의 주소를 사용하기 전에 p에 저장된 주소가 NULL 인지 체크를 하는 것입니다. 그래서 p 변수의 값이 NULL이면 아직 주소가 대입이 안된 것이기 때문에 주소를 사용하는 코드가 동작하지 않도록 처리하는 것입니다.

#include <stdio.h> int main() { char *p = NULL; if (p == NULL) { printf("포인터에 주소가 할당되지 않았습니다!\n"); } else { *p = 10; printf("%d\n", *p); } return 0; }

이렇게 처리하면 실행해도 오류가 발생하지 않고 문제 상황을 사용자가 확인할 수 있게 해줍니다.

포인터에 주소가 할당되지 않았습니다! 계속하려면 아무 키나 누르십시오 . . .


5. 문제가 발생할 확률이 매우 낮은 경우!
프로그램을 하다 보면 실제로 문제가 발생할 확률이 거의 없지만 혹시 모를 상황 때문에 오류 처리를 하는 경우가 있습니다. 예를 들어, 아래와 같은 코드입니다. malloc 함수는 지정한 크기만큼 동적으로 메모리를 할당해주는 함수인데 아래의 예제에서는 16 바이트 크기의 메모리를 할당하도록 사용되었습니다. 하지만 malloc 함수는 반드시 성공하는 함수가 아니기 때문에 메모리 할당에 실패하면 NULL 값을 반환합니다. 그래서 아래와 같이 p에 저장된 값이 NULL 인지 확인하는 조건문을 추가하여 메모리 할당에 실패했는지를 확인하는 것입니다.

그런데 요즘과 같은 컴퓨터 환경에서 malloc 함수가 실패할 확률은 매우 낮습니다. 만약, 자신의 프로그램에서 malloc 함수가 16 바이트 메모리 할당에 실패했다면 메모리 관리 체계를 당장 고쳐야 할 정도로 심각한 상황입니다. 그래서 아래와 같은 조건문이 추가되어 있는 것은 사실 프로그램 실행 성능만 저하시킬 뿐 아무런 도움이 되지 않는 코드입니다. 하지만 혹시 모를 아주 낮은 확률 때문에 저렇게 코드를 작성하는 개발자들이 아직도 많습니다.

#include <stdio.h> #include <malloc.h> // malloc 함수를 사용하기 위하여! int main() { // 16 바이트 메모리를 동적으로 할당하여 그 주소를 p에 저장한다. // 메모리 할당에 실패하면 malloc는 NULL 값을 반환한다. char *p = (char *)malloc(16); if (p == NULL) { printf("메모리 할당에 실패했습니다!\n"); } else { *p = 10; printf("%d\n", *p); free(p); } return 0; }


6. assert 매크로를 활용하자!
결국 버그가 발생하면 소스 전체에서 문제를 찾게 되는데 사소한 문제라도 조건문을 처리해두면 문제 해결에 도움이 되기 때문에 많은 개발자들이 꼼꼼하게 예외 처리를 하는데 이것이 결국 프로그램의 성능을 저하시키는 원인이 되기도 합니다.

그래서 확률이 낮은 예외 처리에는 assert 매크로를 활용하는 것이 좋습니다. assert 매크로는 자신의 괄호에 사용된 조건이 거짓이 되었을 때 발생하기 때문에 아래와 같이 코드를 추가하면 됩니다. 그리고 assert 매크로를 사용하려면 assert.h 헤더 파일을 자신의 소스에 포함시켜야 합니다.

#include <stdio.h> #include <assert.h> // assert 매크로를 사용하기 위하여! #include <malloc.h> // malloc 함수를 사용하기 위하여! int main() { // 16 바이트 메모리를 동적으로 할당하여 그 주소를 p에 저장한다. // 메모리 할당에 실패하면 malloc는 NULL 값을 반환한다. char *p = (char *)malloc(16); assert(p != NULL); // p가 NULL이면 디버그 오류를 출력해준다! *p = 10; printf("%d\n", *p); free(p); return 0; }

위와 같이 작성된 코드를 실행해서 malloc 함수가 정상적으로 동작하면 아무런 오류 없이 실행되고 아래와 같이 출력됩니다.

10 계속하려면 아무 키나 누르십시오 . . .

하지만 malloc 함수가 메모리 할당에 실패하여 포인터 변수 p에 NULL이 저장되면 아래와 같이 디버그 오류가 출력됩니다. 그리고 그림을 보면 알겠지만 어떤 assert에 의해서 오류가 발생했는지 그리고 어떤 소스의 몇 번째 줄에서 오류가 발생했는지까지 상세하게 출력되기 때문에 조건문으로 예외 처리하는 것보다 더 쉽게 오류를 찾아서 해결할 수 있습니다.

그리고 assert의 좋은 점은 디버깅 모드에서만 해당 코드가 동작한다는 뜻입니다. 즉, 프로그램 개발이 완성되어 배포할 때는 소스를 Debug 모드가 아닌 Release 모드로 컴파일하게 되는데 Release 모드로 컴파일하면 assert 코드는 모두 제거가 된 상태로 컴파일이 됩니다. 따라서 오류 체크에 대한 코드가 모두 사라졌기 때문에 조건문으로 예외 처리를 하는 코드보다 수행 능력이 좋을 수밖에 없습니다.

스티커 이미지



7. Debug 모드에서 assert를 사용하고 싶지 않다면?
Debug 모드에서 특정 상황을 위해 assert 매크로 동작을 중지하고 싶다면 NDEBUG 상수를 아래와 같이 assert.h 헤더 파일 보다 위쪽에 선언하면 됩니다. 즉, 컴파일 모드를 Release 모드로 변경하지 않더라도 NDEBUG 상수를 사용해서 assert 매크로를 중단할 수 있다는 뜻입니다.

#define NDEBUG // assert 매크로를 무력화 시킨다. #include <stdio.h> #include <assert.h> // assert 매크로를 사용하기 위하여! #include <malloc.h> // malloc 함수를 사용하기 위하여! int main() { // 16 바이트 메모리를 동적으로 할당하여 그 주소를 p에 저장한다. // 메모리 할당에 실패하면 malloc는 NULL 값을 반환한다. char *p = (char *)malloc(16); assert(p != NULL); // p가 NULL이면 디버그 오류를 출력해준다! *p = 10; printf("%d\n", *p); free(p); return 0; }

이렇게 되는 이유는 assert.h 헤더 파일을 보면 알 수 있습니다. assert.h 헤더 파일에 보면 아래와 같이 assert 매크로가 NDEBUG가 정의되었을 때 0값으로 변환되도록 되어있습니다. 그래서 컴파일시에 0으로 번역되고 이것은 의미 없는 코드이기 때문에 실행 코드 최적화 단계에서 모두 제거됩니다.

#ifdef NDEBUG #define assert(expression) ((void)0) #else _ACRTIMP void __cdecl _wassert( _In_z_ wchar_t const* _Message, _In_z_ wchar_t const* _File, _In_ unsigned _Line ); #define assert(expression) (void)( \ (!!(expression)) || \ (_wassert(_CRT_WIDE(#expression), _CRT_WIDE(__FILE__), (unsigned)(__LINE__)), 0) \ ) #endif


8. 결론
assert 매크로가 디버깅에 좋긴 하지만 모든 예외 처리 상황에 다 사용하면 불편할 수밖에 없습니다. 왜냐하면 디버그 오류 메시지 창이 계속 출력되어 창을 닫는데 시간을 더 많이 소모하기 때문입니다. 따라서 자주 발생하는 예외에 대해서는 당연히 조건문으로 확실히 예외 처리를 하는 것이 좋고 위에서 설명한 것처럼 확률이 낮은데 혹시 모를 예외를 체크하고 싶다면 assert를 사용하는 것이 좋다는 뜻입니다.

assert 매크로는 Debug 모드에서만 동작한다는 것을 잊지 마세요!

스티커 이미지




'MFC' 카테고리의 다른 글

툴박스에 텍스트 삽입  (0) 2019.03.30
OnCreate() ,OnInitialUpdate() 비교  (0) 2019.03.22
비트맵 처리  (0) 2019.03.19
CDC 클래스  (0) 2019.03.18
SDI 에서 타이틀에 표시되는 문자열 수정하기  (0) 2019.03.17