[C언어 50강] 41강. 에러 처리: 반환값 규칙, errno/perror, 실패 설계

[C언어 50강] 41강. 에러 처리: 반환값 규칙, errno/perror, 실패 설계

C 프로그램이 실무에서 오래 버티는지, 배포 후 장애를 줄이는지는 정상 동작 코드보다 실패를 다루는 코드에서 갈립니다. 많은 초급 예제는 성공 경로(happy path)만 보여주지만, 실제 운영 환경에서는 파일 권한 문제, 잘못된 입력, 메모리 부족, 네트워크 지연처럼 실패가 더 자주 나타납니다. 이번 강의에서는 에러 처리를 “예외 상황 땜빵”이 아니라 설계의 일부로 보는 관점을 잡아보겠습니다.


핵심 개념

  • 에러 처리는 if (실패) { ... }를 덧붙이는 일이 아니라, 함수 계약(입력/출력/실패 의미)을 정의하는 설계 작업이다.
  • C의 에러 신호는 주로 반환값 + errno + 로그(예: perror, strerror) 조합으로 전달한다.
  • 실패를 빨리 감지하고, 자원을 일관되게 정리하며, 호출자에게 복구 가능한 정보를 넘기는 것이 핵심이다.

개념 먼저 이해하기

C는 다른 고수준 언어처럼 기본 예외(exception) 메커니즘이 없습니다. 그래서 초보자는 에러 처리를 “불편한 수동 작업”으로 느끼기 쉽습니다. 하지만 관점을 바꾸면 C의 장점이 보입니다. C에서는 실패의 전달 방식을 개발자가 명확하게 선택합니다. 예를 들어 어떤 함수는 0 성공/음수 실패를 쓸 수 있고, 어떤 함수는 포인터를 반환하며 실패 시 NULL을 반환합니다. 시스템 호출 계열은 실패 시 errno를 세팅합니다. 즉, 실패가 코드 흐름 안에서 명시적으로 보이기 때문에, 팀 규칙만 잘 잡으면 오히려 유지보수성이 좋아집니다.

중요한 포인트는 “실패”의 종류를 구분하는 것입니다. 첫째, 사용자 입력 오류(예: 숫자 대신 문자 입력). 둘째, 환경 오류(파일 없음, 권한 없음, 디스크 가득 참). 셋째, 프로그래머 오류(NULL 전달, 경계값 누락, 잘못된 상태). 이 셋을 모두 같은 방식으로 처리하면 디버깅이 어려워집니다. 예를 들어 사용자 입력 오류는 사용자에게 재입력을 유도해야 하고, 환경 오류는 운영 로그와 함께 원인을 남겨야 하며, 프로그래머 오류는 assert나 방어 코드로 조기 탐지해야 합니다.

errno는 자주 오해되는 주제입니다. errno는 “항상 마지막 에러 코드”가 아닙니다. 에러가 발생했을 때만 의미가 있고, 성공한 함수 호출 후의 errno 값은 이전 값이 남아 있을 수 있습니다. 그래서 errno 기반 판별은 반드시 “함수가 실패를 반환했는지”를 먼저 확인한 뒤에 해야 합니다. 예를 들어 fopenNULL을 반환했을 때만 errno를 확인해야 합니다. perror("fopen")는 현재 errno에 해당하는 메시지를 접두어와 함께 출력해주므로, 장애 분석에서 매우 유용합니다.

또 하나 핵심은 에러 전파 전략입니다. 하위 함수에서 실패했을 때 상위 함수는 세 가지 중 하나를 해야 합니다. (1) 즉시 복구 가능하면 복구 후 계속 진행, (2) 복구 불가능하면 정리(cleanup) 후 호출자에게 실패 전파, (3) 프로그램 전체 정책상 치명적이면 종료. 이때 “정리 후 전파”를 습관화해야 메모리 누수, 파일 핸들 누수, mutex 미해제 같은 2차 장애를 막을 수 있습니다.

실무에서는 이 패턴을 반복합니다. 입력 검증 → 하위 API 호출 → 실패 시 맥락 포함 로그 → 자원 정리 → 표준화된 오류 코드 반환. 코드가 길어지더라도 가독성을 지키기 위해 goto cleanup 패턴을 자주 씁니다. C 초급자에게 goto는 금기처럼 보일 수 있지만, 무분별한 점프가 문제인 것이지 단일 정리 지점으로 점프하는 용도는 오히려 안전합니다. 중요한 건 규칙입니다: 점프는 cleanup 레이블로만, 상태 플래그를 명확히, 해제 순서를 역순으로 유지.

결론적으로 C 에러 처리의 본질은 “문법”이 아니라 “계약”입니다. 함수가 어떤 실패를 어떤 형태로 알리는지, 호출자는 무엇을 보장해야 하는지, 실패 후 시스템 상태를 어떻게 일관되게 유지하는지를 문서화하고 코드로 강제해야 합니다. 이 기준이 잡히면 기능 추가가 빨라지고, 장애 대응 시간은 눈에 띄게 줄어듭니다.

기본 사용

예제 1) 반환값 규칙과 errno 확인의 기본

#include <stdio.h>
#include <errno.h>
#include <string.h>

int main(void) {
    const char *path = "not_exist.txt";
    FILE *fp = fopen(path, "r");

    if (fp == NULL) {
        // 실패를 먼저 확인한 뒤 errno를 사용한다.
        fprintf(stderr, "fopen failed: path=%s, errno=%d(%s)\n",
                path, errno, strerror(errno));
        return 1;
    }

    fclose(fp);
    return 0;
}

설명:

  • fopen의 실패 신호는 NULL입니다. 이 조건이 참일 때만 errno를 해석해야 합니다.
  • strerror(errno)는 사람이 읽을 수 있는 문자열을 제공합니다.
  • 실무에서는 경로, 모드, 사용자 입력값처럼 맥락 정보를 함께 출력해야 원인 추적이 빨라집니다.

예제 2) 에러 코드 enum으로 함수 계약 고정하기

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

typedef enum {
    APP_OK = 0,
    APP_ERR_INVALID_ARG = 1,
    APP_ERR_IO = 2,
    APP_ERR_NOMEM = 3
} AppError;

AppError read_first_int(const char *path, int *out_value) {
    if (path == NULL || out_value == NULL) {
        return APP_ERR_INVALID_ARG;
    }

    FILE *fp = fopen(path, "r");
    if (!fp) {
        return APP_ERR_IO;
    }

    int n;
    if (fscanf(fp, "%d", &n) != 1) {
        fclose(fp);
        return APP_ERR_IO;
    }

    fclose(fp);
    *out_value = n;
    return APP_OK;
}

int main(void) {
    int value = 0;
    AppError err = read_first_int("input.txt", &value);

    if (err != APP_OK) {
        fprintf(stderr, "read_first_int failed: err=%d\n", err);
        return err;
    }

    printf("value=%d\n", value);
    return 0;
}

설명:

  • 반환 타입을 AppError로 고정하면 “무슨 실패가 가능한가”가 코드에서 드러납니다.
  • errno를 외부로 그대로 노출하지 않고, 애플리케이션 수준 오류 코드로 매핑하는 방식은 API 안정성에 유리합니다.
  • out-parameter(int *out_value)는 성공 시에만 값을 쓴다는 계약을 지켜야 호출자가 안전하게 사용합니다.

예제 3) goto cleanup으로 자원 정리 일원화

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

int process_file(const char *in_path, const char *out_path) {
    int rc = -1;
    FILE *in = NULL;
    FILE *out = NULL;
    char *buf = NULL;

    in = fopen(in_path, "r");
    if (!in) goto cleanup;

    out = fopen(out_path, "w");
    if (!out) goto cleanup;

    buf = (char *)malloc(1024);
    if (!buf) goto cleanup;

    if (!fgets(buf, 1024, in)) goto cleanup;
    if (fprintf(out, "copied: %s", buf) < 0) goto cleanup;

    rc = 0; // 성공

cleanup:
    free(buf);
    if (out) fclose(out);
    if (in) fclose(in);
    return rc;
}

설명:

  • 중간 단계에서 실패해도 cleanup 하나에서 자원 해제가 이루어집니다.
  • 해제 코드는 “여러 return 지점마다 복붙”하지 말고 한 곳으로 모으는 것이 누수 방지에 유리합니다.
  • rc를 통해 성공/실패를 명확히 전달하고, 상위 호출자가 다음 정책(재시도/중단)을 결정하게 합니다.

자주 하는 실수

실수 1) errno를 실패 확인 없이 읽기

  • 원인: errno를 전역 상태처럼 오해해, 함수 성공 여부와 무관하게 출력함.
  • 해결: 항상 “반환값으로 실패 판정 → 그다음 errno 해석” 순서를 지킨다.

실수 2) 에러 메시지에 맥락을 남기지 않기

  • 원인: perror("error")처럼 정보가 너무 적어 어떤 입력에서 깨졌는지 알 수 없음.
  • 해결: 파일 경로, 함수명, 파라미터를 함께 로깅한다. 예: fopen failed path=%s.

실수 3) 중간 실패에서 자원 해제를 빼먹기

  • 원인: return 분기가 많아질수록 fclose/free 누락이 발생.
  • 해결: 단일 정리 지점(goto cleanup) 패턴을 팀 규칙으로 사용한다.

실수 4) 실패 코드를 설계하지 않고 숫자를 즉흥적으로 사용

  • 원인: -1, -2, -3 의미가 문서화되지 않아 호출자 코드가 해석 불가.
  • 해결: enum 기반 오류 코드 체계를 만들고, 각 코드의 의미/복구 전략을 문서화한다.

실무 패턴

  • 함수 계약 템플릿을 만든다: 입력 제약, 성공 반환값, 실패 코드, 부수효과, 자원 소유권.
  • 경계에서 검증한다: 외부 입력(파일/네트워크/CLI 인자)은 진입점에서 강하게 검증.
  • 로그 레벨 분리: 사용자 메시지와 개발자 디버그 로그를 분리해 운영 소음을 줄인다.
  • 실패 주입 테스트: 파일 오픈 실패, 메모리 할당 실패를 인위적으로 넣어 cleanup 경로를 검증.
  • 오류 전파 일관성: 하위 계층의 세부 오류를 상위 계층 도메인 오류로 매핑해 API를 단순화.

오늘의 결론

한 줄 요약: 좋은 C 코드는 “성공 경로”보다 “실패 경로”를 먼저 설계한 코드다.

연습문제

  1. load_config(const char *path, Config *out) 함수를 설계하라. 입력 검증 실패/파일 없음/파싱 실패를 서로 다른 오류 코드로 구분하고, 호출부에서 각각 다른 메시지를 출력하라.
  2. 파일 2개를 열어 한 줄씩 비교하는 함수를 작성하라. 중간 실패가 발생해도 파일 핸들이 항상 닫히도록 goto cleanup 패턴으로 구현하라.
  3. errno, perror, strerror를 각각 사용하는 버전을 만들어 로그 품질을 비교하라. 어떤 정보가 장애 재현에 더 유리한지 정리하라.

이전 강의 정답

40강 연습문제(파일 입출력 2) 핵심 정답 요약:

  • 텍스트 파일 복사 문제: fgets로 읽고 fputs로 쓰되, 읽기 실패가 EOF인지 에러인지 feof, ferror로 구분해야 한다.
  • 바이너리 복사 문제: fread/fwrite는 “원소 수” 반환값을 확인해야 하며, 마지막 블록은 요청 크기보다 작을 수 있다.
  • 구조체 저장 문제: 바이너리 직렬화는 구조체 패딩/엔디안 차이 이슈가 있으므로, 동일 컴파일러/플랫폼 전제인지 문서화가 필요하다.

실습 환경/재현 정보

  • 컴파일러: Apple clang version 17.x 이상
  • 컴파일 옵션: -std=c11 -Wall -Wextra -Wpedantic -O0 -g
  • 실행 환경: macOS (arm64), 터미널 zsh
  • 재현 체크:
    • 존재하지 않는 파일 경로로 fopen 실패 로그 확인
    • 읽기 전용 파일에 쓰기 모드 시도 후 실패 처리 확인
    • malloc 실패 시나리오는 테스트 더블(래퍼 함수)로 주입 검증