출시·고도화 중
C 안내서 · 5/6
C에는 예외가 없습니다. 오류는 함수의 반환값과 errno로 알리고, 호출한 쪽이 매번 직접 확인해야 합니다. 이 장에서는 오류를 알리고 확인하는 관례, assert와 방어적 검사, 메모리 오류를 찾는 새니타이저, 그리고 간단한 단위 테스트 방법을 다룹니다.
가장 흔한 관례는 함수가 성공하면 0, 실패하면 0이 아닌 값(보통 음수)을 돌려주고, 실제 결과는 포인터 매개변수(출력 매개변수)로 넘기는 것입니다. 포인터를 돌려주는 함수는 실패를 NULL로 알립니다.
#include <stdio.h>
enum { OK = 0, ERR_DIV_ZERO = -1, ERR_NULL = -2 };
int safe_divide(int a, int b, int *out) {
if (out == NULL) return ERR_NULL;
if (b == 0) return ERR_DIV_ZERO;
*out = a / b;
return OK;
}
int main(void) {
int result;
int rc = safe_divide(10, 0, &result);
if (rc != OK) {
fprintf(stderr, "나눗셈 실패 (코드 %d)\n", rc);
return 1;
}
printf("%d\n", result);
return 0;
}반환값을 무시하는 것이 C에서 가장 흔한 버그 원인 중 하나입니다. 실패할 수 있는 함수를 부르면 결과를 쓰기 전에 항상 반환값부터 확인합니다.
표준 라이브러리 함수 상당수는 실패하면 특별한 값(NULL, -1, EOF 등)을 돌려주고, 이유를 전역 변수 errno(<errno.h>)에 남깁니다. strerror(errno)나 perror로 사람이 읽을 수 있는 메시지를 얻습니다.
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "사용법: %s <파일> [숫자]\n", argv[0]);
return 1;
}
FILE *fp = fopen(argv[1], "r");
if (fp == NULL) {
fprintf(stderr, "%s 열기 실패: %s\n", argv[1], strerror(errno));
return 1;
}
fclose(fp);
if (argc > 2) {
char *end;
errno = 0; /* strtol 전에 0으로 초기화 */
long n = strtol(argv[2], &end, 10);
if (errno == ERANGE) {
fprintf(stderr, "범위를 벗어난 숫자\n");
} else if (end == argv[2] || *end != '\0') {
fprintf(stderr, "숫자가 아닌 입력\n");
} else {
printf("n = %ld\n", n);
}
}
return 0;
}errno는 실패했을 때만 의미가 있습니다. 성공한 함수가 errno를 0으로 되돌려 놓지는 않으므로, 반환값으로 실패를 먼저 확인한 다음 errno를 읽습니다. strtol처럼 반환값만으로 실패를 구분할 수 없는 함수는 호출 전에 errno = 0으로 초기화합니다. 숫자 변환에 atoi를 쓰면 오류를 전혀 알 수 없으므로 strtol을 씁니다.
assert(<assert.h>)는 "여기서는 반드시 참이어야 한다"는 프로그래머의 가정을 확인합니다. 조건이 거짓이면 파일 이름과 줄 번호를 출력하고 프로그램을 중단합니다. NDEBUG 매크로를 정의하고 빌드하면 모든 assert가 사라지므로, 사용자 입력이나 파일 내용처럼 실행 중에 충분히 틀릴 수 있는 값은 assert가 아니라 일반 if 검사로 처리해야 합니다.
#include <assert.h>
#include <stdlib.h>
#include <string.h>
/* 내부 함수: 호출자가 지켜야 할 조건은 assert로 확인 */
static void copy_ints(int *dst, const int *src, size_t n) {
assert(dst != NULL && src != NULL);
memcpy(dst, src, n * sizeof *dst);
}
/* 공개 함수: 바깥에서 오는 값은 실제로 검사하고 오류를 돌려줌 */
int *duplicate(const int *src, size_t n) {
if (src == NULL || n == 0) return NULL;
int *copy = malloc(n * sizeof *copy);
if (copy == NULL) return NULL; /* 메모리 부족 */
copy_ints(copy, src, n);
return copy;
}자원을 여러 개 다루는 함수에서는 실패 지점마다 정리 코드를 반복하기보다 goto cleanup으로 한 곳에 모아 해제하는 패턴이 C에서 널리 쓰입니다.
범위를 벗어난 접근, 해제한 메모리 사용, 부호 있는 정수 오버플로 같은 문제는 프로그램이 멀쩡해 보이다가 나중에 엉뚱한 곳에서 터집니다. GCC와 Clang의 새니타이저는 이런 문제를 실행 중에 바로 잡아 줍니다.
gcc -std=c17 -g -O1 -fno-omit-frame-pointer \
-fsanitize=address,undefined bug.c -o bug
./bug// bug.c
#include <stdlib.h>
#include <limits.h>
int main(void) {
int *a = malloc(4 * sizeof *a);
a[4] = 1; /* ASan: heap-buffer-overflow */
free(a);
int x = INT_MAX;
x = x + 1; /* UBSan: signed integer overflow */
return x == 0;
}새니타이저를 켜고 실행하면 문제가 생긴 줄과 메모리를 할당한 위치까지 보고서로 출력됩니다. 실행이 느려지고 메모리를 더 쓰므로 개발과 테스트 빌드에서만 켭니다. MSVC도 /fsanitize=address로 ASan을 지원합니다.
C 표준 라이브러리에는 테스트 프레임워크가 없지만, 작은 테스트 프로그램만으로도 충분히 시작할 수 있습니다. 실패 횟수를 세고 종료 상태로 결과를 알리면 Make나 CMake, CI가 그대로 활용할 수 있습니다.
// test_mathutil.c
#include <stdio.h>
#include "mathutil.h"
static int failures = 0;
#define CHECK(cond) do { \
if (!(cond)) { \
fprintf(stderr, "%s:%d: 실패: %s\n", __FILE__, __LINE__, #cond); \
failures++; \
} \
} while (0)
int main(void) {
CHECK(clamp(5, 0, 10) == 5);
CHECK(clamp(-3, 0, 10) == 0);
CHECK(clamp(42, 0, 10) == 10);
printf("%s\n", failures ? "FAILED" : "OK");
return failures ? 1 : 0;
}CMake를 쓴다면 enable_testing()과 add_test(NAME mathutil COMMAND test_mathutil)을 추가하고 build 디렉터리에서 ctest로 실행합니다. 테스트가 많아지면 Unity, cmocka, Check 같은 C용 테스트 프레임워크를 검토합니다. 테스트 빌드에 새니타이저를 함께 켜 두면 메모리 오류까지 한 번에 잡을 수 있습니다.
NULL 실패)으로 알리고, 호출한 쪽은 항상 확인합니다.errno는 실패를 확인한 다음에만 읽고, strerror나 perror로 메시지를 만듭니다.assert는 프로그래머의 가정을 확인하는 용도이며, 외부 입력 검사는 if로 합니다.-fsanitize=address,undefined로 메모리 오류와 정의되지 않은 동작을 잡습니다.
댓글 0개
로그인 · 로그인하면 댓글을 남길 수 있습니다.
첫 댓글을 남겨 보세요.