Devin.KR

오류 처리 규약 - 반환값·errno·로그를 일관되게

개발자KR 조회 12

이 장에서 배우는 것

C 언어는 예외(exception) 처리 문법을 제공하지 않는다. 함수가 실패했을 때 그 사실을 호출자에게 알리는 책임은 오롯이 프로그래머에게 있다. 기본서에서 반환값을 검사하는 기초적인 방법을 다루었다면, 이 장에서는 여러 계층으로 이루어진 프로그램에서 오류를 일관되게 추적하고 자원 누수를 막는 구조를 설계한다. 작은 도서관 대출 관리 프로그램의 회원 정보 파일을 읽어오는 예제를 통해 실무적인 오류 처리 규약을 알아본다.

  • 성공과 실패를 나타내는 일관된 반환 타입과 값 규칙을 정한다.
  • 표준 C 라이브러리의 전역 변수인 errno의 특징과 올바른 저장 시점을 이해한다.
  • 오류가 발생했을 때 누수 없이 자원을 정리하는 단일 탈출구(single exit point) 패턴을 적용한다.
  • assert 매크로를 사용하여 개발 단계에서 논리적 오류를 일찍 발견한다.

문제 상황

도서관 시스템은 파일 시스템에서 데이터를 읽고, 메모리를 할당하며, 네트워크로 알림을 보낸다. 이렇게 여러 작업이 연달아 일어나는 함수에서 각 단계마다 오류를 처리하다 보면 코드가 복잡해진다.

FILE *fp = fopen("user.dat", "r");
if (fp == NULL) {
    return -1;
}
char *buffer = malloc(1024);
if (buffer == NULL) {
    fclose(fp);
    return -1;
}
if (fread(buffer, 1, 1024, fp) < 1024) {
    free(buffer);
    fclose(fp);
    return -1;
}
// 작업 완료
free(buffer);
fclose(fp);
return 0;

위 코드처럼 작업 단계가 하나씩 늘어날 때마다 실패 시 해제해야 할 자원이 쌓인다. 자원 해제 코드가 중복되고, 실수로 free나 fclose를 빼먹으면 메모리 누수나 파일 디스크립터(file descriptor) 고갈로 이어진다. 또한 오류가 발생했다는 사실(-1)만 반환할 뿐, 파일이 없는 것인지, 권한이 없는 것인지, 메모리가 부족한 것인지 구체적인 원인을 호출자가 알 방법이 없다.

반환 코드 설계

오류를 알리는 가장 기본적인 방법은 함수의 반환값을 이용하는 것이다. 반환값 규약은 프로젝트 전체에서 일관되게 유지해야 한다.

정수 반환 규약

가장 널리 쓰이는 방식은 함수가 int를 반환하게 하고, 성공 시 0을, 실패 시 음수를 반환하는 것이다. 구체적인 오류 원인을 전달하기 위해 음수 값을 미리 매크로나 열거형(enum)으로 정의해 둔다.

enum lib_error {
    LIB_SUCCESS = 0,
    LIB_ERR_NO_MEM = -1,
    LIB_ERR_FILE_IO = -2,
    LIB_ERR_INVALID = -3
};

int load_record(const char *path) {
    if (path == NULL) return LIB_ERR_INVALID;
    // ...
    return LIB_SUCCESS;
}

이 방식은 조건문에서 직관적으로 검사할 수 있다. if (load_record("data.bin") < 0) 처럼 음수인지 확인하여 오류를 감지한다.

포인터 반환 규약

문자열이나 구조체를 동적 할당하여 반환하는 함수는 실패 시 NULL을 반환하는 것이 관례다. 하지만 NULL만으로는 실패 원인을 알 수 없다. 이 경우 실제 데이터는 매개변수의 이중 포인터(double pointer)를 통해 전달하고, 함수의 반환값으로는 성공 여부를 나타내는 정수 상태 코드를 사용하는 방식이 안전하다.

// 반환값은 상태 코드, 결과는 매개변수(out_record)로 전달
int create_user_record(int id, user_record_t **out_record);

전역 오류 변수 errno와 로그

표준 C 라이브러리와 POSIX 운영체제 API는 오류가 발생했을 때 그 원인을 전역 변수(정확히는 스레드 지역 변수)인 errno에 기록한다. fopen, malloc 같은 표준 함수가 실패하여 NULL을 반환했다면, <errno.h>에 선언된 errno 값을 읽어 구체적인 이유를 알아낼 수 있다.

errno 저장 시점

errno를 다룰 때 가장 중요한 원칙은, 오류가 발생한 직후 즉시 다른 변수에 값을 복사해 두어야 한다는 것이다. errno는 성공한 함수 호출에 의해 0으로 초기화되지 않지만, 오류 처리 과정에서 호출한 다른 라이브러리 함수(예: printf나 로그 함수)가 내부적으로 실패하면서 errno 값을 덮어쓸 위험이 있다.

FILE *fp = fopen("missing.txt", "r");
if (fp == NULL) {
    int saved_errno = errno; // 즉시 저장
    printf("파일 열기 실패\n"); // 이 함수가 errno를 바꿀 수 있음
    fprintf(stderr, "원인: %s\n", strerror(saved_errno));
}

오류 메시지 출력

errno 정수값을 사람이 읽을 수 있는 문자열로 바꾸려면 strerror 함수를 사용한다. 또는 perror 함수를 사용하면 사용자가 지정한 문자열 뒤에 콜론을 붙이고 해당 errno의 메시지를 표준 에러(stderr)로 출력해 준다. 실무에서는 파일 이름이나 현재 실행 중인 함수 이름을 함께 로그로 남기는 것이 디버깅에 유리하다.

자원 정리와 goto 단일 탈출구

C 언어에서 함수 중간에 오류가 발생했을 때 할당된 자원을 누수 없이 해제하는 가장 깔끔한 방법은 goto 문을 활용한 단일 탈출구(single exit point) 패턴이다. 함수 끝에 자원을 해제하는 코드를 역순으로 배치하고, 오류가 발생하면 해당 위치로 건너뛴다.

int process_data(void) {
    int status = LIB_ERR_FILE_IO;
    FILE *fp = NULL;
    char *buf = NULL;

    fp = fopen("data.txt", "r");
    if (!fp) goto err_fp;

    buf = malloc(1024);
    if (!buf) {
        status = LIB_ERR_NO_MEM;
        goto err_buf;
    }

    // 작업 수행...
    status = LIB_SUCCESS;

err_buf:
    if (buf) free(buf);
err_fp:
    if (fp) fclose(fp);
    return status;
}

초기화한 자원은 그 역순으로 해제하는 것이 안전하다. fp를 먼저 열고 buf를 나중에 할당했다면, 해제할 때는 buf를 먼저 확인하여 풀고 그 다음 fp를 닫는다. 오류가 발생한 시점에 따라 건너뛰는 라벨(label) 위치를 다르게 지정하면 필요한 자원만 정확히 해제할 수 있다. 변수를 포인터의 경우 NULL로 초기화해 두면 해제 부분에서 안전하게 if로 검사할 수 있다.

assert 매크로의 활용

<assert.h> 헤더가 제공하는 assert 매크로는 프로그램의 논리적 가정을 검증한다. 조건식이 거짓(0)으로 평가되면 프로그램은 즉시 오류 메시지를 출력하고 강제 종료(abort)된다.

assert는 복구할 수 없는 치명적인 오류나, 프로그래머의 실수로 발생한 버그를 조기에 발견하기 위해 쓴다. 예를 들어 함수의 포인터 매개변수가 절대 NULL이어서는 안 된다는 계약(contract)을 명시할 때 유용하다. 반면 파일이 없거나 메모리 할당이 실패하는 등 실행 환경에서 자연스럽게 발생할 수 있는 상황은 assert가 아니라 if와 반환값으로 처리해야 한다. 컴파일할 때 NDEBUG 매크로를 정의하면 모든 assert 검사는 코드에서 완전히 제거된다.

void update_record(user_record_t *rec) {
    assert(rec != NULL); // 프로그래머의 실수 방지용
    // ...
}

완성 코드

도서관 회원 정보를 담은 파일을 읽어서 동적 할당된 구조체에 저장하는 예제다. 단일 탈출구 패턴과 errno를 활용한 로그 기록을 보여준다. member.h와 member.c, 그리고 실행을 위한 main.c로 구성된다.

member.h

#ifndef MEMBER_H
#define MEMBER_H

typedef struct {
    int id;
    char name[64];
} member_record_t;

enum lib_status {
    LIB_OK = 0,
    LIB_ERR_PARAM = -1,
    LIB_ERR_MEMORY = -2,
    LIB_ERR_IO = -3,
    LIB_ERR_FORMAT = -4
};

// 회원 레코드를 읽어 아웃 매개변수(out_record)로 반환한다.
int member_load(const char *filepath, member_record_t **out_record);

// 오류 코드를 문자열로 변환한다.
const char *member_strerror(int status_code);

#endif

member.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <assert.h>
#include "member.h"

const char *member_strerror(int status_code) {
    switch (status_code) {
        case LIB_OK: return "Success";
        case LIB_ERR_PARAM: return "Invalid parameter";
        case LIB_ERR_MEMORY: return "Memory allocation failed";
        case LIB_ERR_IO: return "File I/O error";
        case LIB_ERR_FORMAT: return "Invalid file format";
        default: return "Unknown error";
    }
}

int member_load(const char *filepath, member_record_t **out_record) {
    // 1. 매개변수 검증
    assert(out_record != NULL);
    if (filepath == NULL) {
        return LIB_ERR_PARAM;
    }

    int status = LIB_ERR_IO;
    FILE *fp = NULL;
    member_record_t *record = NULL;

    // 2. 파일 열기
    fp = fopen(filepath, "rb");
    if (fp == NULL) {
        int err = errno;
        fprintf(stderr, "[Error] Failed to open '%s': %s\n", filepath, strerror(err));
        goto cleanup;
    }

    // 3. 메모리 할당
    record = malloc(sizeof(member_record_t));
    if (record == NULL) {
        fprintf(stderr, "[Error] Memory allocation failed\n");
        status = LIB_ERR_MEMORY;
        goto cleanup;
    }

    // 4. 데이터 읽기
    if (fscanf(fp, "%d %63s", &record->id, record->name) != 2) {
        fprintf(stderr, "[Error] Invalid format in '%s'\n", filepath);
        status = LIB_ERR_FORMAT;
        goto cleanup;
    }

    // 5. 성공 처리
    *out_record = record;
    record = NULL; // cleanup에서 해제되지 않도록 소유권 이전
    status = LIB_OK;

cleanup:
    // 6. 자원 정리 (초기화 역순)
    if (record != NULL) {
        free(record);
    }
    if (fp != NULL) {
        fclose(fp);
    }
    
    return status;
}

main.c

#include <stdio.h>
#include <stdlib.h>
#include "member.h"

int main(void) {
    member_record_t *user = NULL;
    int result;

    // 존재하지 않는 파일 테스트
    result = member_load("no_such_file.txt", &user);
    if (result != LIB_OK) {
        printf("Load failed: %s\n", member_strerror(result));
    }

    // 정상 파일 테스트를 위한 임시 파일 생성
    FILE *tmp = fopen("test_user.txt", "w");
    if (tmp) {
        fprintf(tmp, "1042 Alice\n");
        fclose(tmp);
    }

    result = member_load("test_user.txt", &user);
    if (result == LIB_OK) {
        printf("Loaded user: ID=%d, Name=%s\n", user->id, user->name);
        free(user); // 사용 완료 후 메모리 해제
    } else {
        printf("Load failed: %s\n", member_strerror(result));
    }

    remove("test_user.txt"); // 테스트 파일 삭제
    return 0;
}

줄별 해설

  • member.c 21줄: assert(out_record != NULL);는 호출자가 결과를 담을 이중 포인터를 제대로 전달했는지 컴파일 및 실행 초기에 확인한다. 이 값이 NULL이라면 라이브러리 사용 방식이 근본적으로 잘못된 논리적 오류다.
  • member.c 33~35줄: fopen 실패 직후 errno를 지역 변수 err에 복사한다. 이후 fprintf가 출력하는 도중 어떤 이유로 내부적으로 실패하여 errno를 변경하더라도, 복사해 둔 원본 값을 strerror에 전달하므로 안전하다.
  • member.c 36, 44, 51줄: goto cleanup;을 호출하여 함수 하단의 자원 해제 코드가 있는 곳으로 즉시 건너뛴다.
  • member.c 55줄: 함수가 성공했을 때 할당한 메모리 주소를 호출자 측의 변수(*out_record)에 대입하여 반환한다.
  • member.c 56줄: record = NULL;은 매우 중요하다. 성공적으로 작업을 마쳤으므로 동적 할당 메모리의 소유권이 호출자에게 넘어갔다. cleanup 구역에서 free(record)가 실행되는 것을 막기 위해 지역 변수 포인터를 비운다.
  • member.c 60줄~: cleanup 라벨 구역이다. 변수가 NULL이 아닌지 확인한 후 자원을 해제한다. 성공 시에는 record가 NULL이므로 파일만 닫히고 끝난다. 실패 시에는 실패한 지점 이전에 할당된 자원들이 순서대로 닫힌다.

실행 결과

코드를 컴파일하고 실행해 본다. 파일 입출력 오류 메시지가 표준 에러로 출력되고, main 함수의 정상적인 성공 메시지가 뒤따른다.

$ cc -std=c17 -Wall -Wextra member.c main.c -o member_test
$ ./member_test
[Error] Failed to open 'no_such_file.txt': No such file or directory
Load failed: File I/O error
Loaded user: ID=1042, Name=Alice

(시스템의 언어 설정에 따라 strerror의 출력 메시지가 한국어나 다른 언어로 다르게 나타날 수 있다.)

라이브러리 함수와 호출자 사이의 오류 전파 흐름

실무에서 자주 틀리는 것

errno를 보존하지 않고 함수 호출하기

errno를 확인하기 전에 다른 입출력 함수나 복잡한 시스템 호출을 섞어 쓰면 원래 오류 원인을 잃어버릴 수 있다.

// 틀린 예: printf 때문에 errno가 바뀔 수 있다.
FILE *fp = fopen("config.bin", "rb");
if (!fp) {
    printf("설정 파일을 열 수 없습니다.\n");
    fprintf(stderr, "상세: %s\n", strerror(errno)); 
}

// 고친 예: 즉시 지역 변수에 저장한다.
FILE *fp = fopen("config.bin", "rb");
if (!fp) {
    int err = errno;
    printf("설정 파일을 열 수 없습니다.\n");
    fprintf(stderr, "상세: %s\n", strerror(err)); 
}

사용자 입력 검증에 assert 사용하기

assert는 런타임 환경에서 발생하는 예외를 처리하는 용도가 아니다. 파일을 열지 못하거나 네트워크가 끊긴 상황은 정상적인 프로그램 실행 중에도 일어날 수 있다.

// 틀린 예: 파일이 없으면 프로그램이 강제 종료된다.
FILE *fp = fopen("data.txt", "r");
assert(fp != NULL); // NDEBUG 정의 시 검사 자체도 사라짐

// 고친 예: 조건문으로 분기하고 오류 코드를 반환한다.
FILE *fp = fopen("data.txt", "r");
if (fp == NULL) {
    return LIB_ERR_IO;
}

자원 해제 후 포인터 초기화 누락 (Double Free)

루프 안에서 반복적으로 자원을 할당하고 해제하거나, 복잡한 분기에서 자원 정리를 여러 번 호출하게 되는 경우 포인터를 초기화하지 않으면 이미 해제된 메모리를 다시 해제하는 심각한 오류가 발생한다.

// 틀린 예
free(buffer);
// 다른 복잡한 로직...
if (error) {
    free(buffer); // 이미 해제된 포인터 해제 시도
    return -1;
}

// 고친 예
free(buffer);
buffer = NULL; // 안전을 위해 포인터 비우기
// ...
if (error) {
    if (buffer) free(buffer); // 조건이 거짓이 되어 안전함
    return -1;
}
단일 탈출구(Single Exit Point) 패턴을 통한 자원 해제 순서

한눈에 보기

오류 처리 메커니즘 비교
구분 설명 사용 목적 대표 예시
반환값 (상태 코드) 함수의 성공 여부와 논리적 오류 종류를 정수로 반환 일반적인 함수 흐름 제어, 호출자에게 실패 알림 0(성공), 음수 오류 코드 반환
errno 변수 표준 라이브러리 및 시스템 호출이 기록하는 전역 오류 번호 시스템 수준의 실패 원인(권한, 파일 없음 등) 확인 fopen 실패 후 errno 확인
assert 매크로 참이어야 할 조건을 명시하고 거짓일 경우 강제 종료 개발 단계에서 논리적 계약(매개변수 Null 검사 등) 검증 assert(ptr != NULL)
주요 오류 처리 함수 요약
함수명 헤더 역할
perror(const char *s) <stdio.h> 문자열 s와 콜론을 출력하고 현재 errno에 해당하는 메시지 출력
strerror(int errnum) <string.h> 정수 오류 번호를 사람이 읽을 수 있는 오류 메시지 문자열로 변환

연습 문제

  1. fopen 함수가 실패했을 때 오류 원인을 시스템 로그 문자열 포인터로 획득하는 함수는 무엇인가?
  2. 다음 중 assert 매크로를 사용하기 적절한 상황을 고르시오.
    1. 사용자가 입력한 비밀번호 길이가 8자리보다 짧은지 검사할 때
    2. 동적 할당을 요청한 malloc 함수가 NULL을 반환했는지 검사할 때
    3. 정렬 함수에 전달된 배열 포인터가 NULL이 아닌지 명시할 때
    4. 네트워크 소켓 연결이 시간 초과로 끊어졌는지 검사할 때
  3. 함수 내에서 malloc으로 메모리를 할당하고 fopen으로 파일을 연 뒤, 중간에 오류가 발생하면 goto cleanup;으로 이동해 자원을 해제하도록 코드를 작성했다. 정상적으로 작업을 마쳐 호출자에게 메모리 소유권을 넘겨줄 때, cleanup 라벨에서 해당 메모리가 해제되지 않게 하려면 어떻게 해야 하는가?

정답과 해설

  1. strerror 함수다. 오류가 발생한 즉시 errno 변수를 정수형 복사본으로 저장하고, 그 값을 strerror에 전달해 문자열을 얻는다.
  2. c. 함수 설계상 절대 NULL이 들어와서는 안 되는 매개변수를 점검하는 등, 논리적 계약 위반을 잡아내는 데 assert를 쓴다. 사용자 입력이나 시스템 자원 부족 등 실행 중에 발생할 수 있는 상황(a, b, d)은 조건문과 반환값으로 유연하게 처리해야 한다.
  3. 할당된 메모리를 가리키는 포인터 변수에 NULL을 대입한다. 이렇게 하면 cleanup 구역에 도달하더라도 if (ptr != NULL) 조건이 거짓이 되므로 free 함수가 호출되지 않는다. 이를 통해 호출자로 자원의 소유권이 안전하게 이전된다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.