POCU C언어 정주행 13회차 - 예외 처리, 나쁜 오류 처리, 오류 처리 전략

2022. 12. 30. 19:19C언어 복습

1. 예외 처리

C를 배운 사람은 아마 다음과 같은 사실을 알고 있을 것이다.

 

"C는 예외 처리를 위한 기능이 없다."

 

다른 언어는 예외 처리가 있지만 C는 이런 것들을 지원하는 기능이 없기 때문에 C로 짜여진 프로그램은 안정적이지 못할 것이라고 생각할 수 있다. 하지만 이 강의는 운영체제를 예시로 들면서 "예외가 없다고 해서 프로그램이 안정적이지 못한 것은 아니다. 운영체제는 C로 짜여져 있지만 운영체제만큼 안정적인 프로그램을 찾기는 힘들지 않는가?" 라고 이야기 한다.

 

그러면서 다음과 같은 이야기도 했다.

 

"예외 처리를 자동으로 해주는 기능은 오히려 프로그래머를 게으르게 만들 수 있다."

 

사실 이런 부분을 포함해서 오류를 처리하는 부분의 전반적인 내용에서 인문학적인 내용을 포함하고 있다. 그래서 훌륭한 프로그래머가 되려면 이런 인문학적인 부분을 이해하는 것도 중요하다고 한다. 이것에 대한 자세한 내용을 위해 이런 질문을 해보겠다. 

  • 혹시 여기에 C/C++이 아닌 다른 언어를 쓰는 사람이 있는가?
  • 혹시 그 언어는 어떤 오류나 예외에 대해서 자동으로 아주 훌륭하게 처리를 해주는 기능을 가지고 있지 않는가?
  • 그 훌륭한 기능을 믿고 오류가 발생했는데도 그 오류를 무시한 적이 있는가?

여기서 중요한 것은 위의 3개의 질문이 아니다. 더 정확하게 이야기하자면 여기서 중요한 것은 위의 3개의 질문은 그저 다음 질문을 하기 위한 발판에 불과할 뿐이라는 것이다. 다음으로 나올 질문은 다음과 같다.

 

"그럼에도 불구하고 프로그램이 정상적으로 동작한 적이 있는가?"

 

만약 여기서 "예"라고 대답할 수 있는 경우가 나온다면 일반적인 사람들은 프로그램이 동작하는 데 있어서 아무런 문제가 없기 때문에 그냥 덮고 넘어가게 된다. 당장 크래시가 나지 않으니 그냥 못본 척 넘어가고 다른 사람이 고쳐주기를 기대하는 것이다. 또한 자신이 코드를 짤 때 자신이 짠 코드가 오류가 날 것이라고 생각하지 않고 코드를 작성하는 경우도 많다. 하지만 크래시가 난다면 얘기가 완전히 달라진다. 일단 프로그램이 정상적으로 잘 작동하다가 크래시가 나버리면 항의가 들어오게 되고 결국 프로그래머는 어쩔 수 없이 고쳐야하기 때문이다.

 

 

2. 나쁜 오류 처리

누군가가 "2개의 int형 변수를 swap시킬 수 있는 함수를 만들어 주세요." 라고 이야기하면 어떻게 코드를 짜야할까? 아마 많은 사람들이 아래와 같이 코드를 짜게 될 것이다.

void swap(int* a, int* b)
{
    int temp;
    temp = *a;
    *a = *b;
    *b = temp;
}

그런데 혹시 '저 a, b라는 포인터 변수에 NULL이 들어오면 어떡하지?' 라는 생각을 해본 적이 있는가? 그리고 그것에 대한 처리를 어떻게 해야하는지 고민한 적이 있는가? 만약 누군가가 이런 의문을 제기하면서 이 문제를 해결할 수 있도록 코드를 수정하라고 한다면 아마 많은 사람들이 다음과 같이 코드를 수정할 것이다.

void swap(int* a, int* b)
{
    int temp;
    
    if (a == NULL || b == NULL)
    {
        return;
    }
    
    temp = *a;
    *a = *b;
    *b = temp;
}

이제 NULL이 들어오더라도 코드가 잘 돌아갈 것이다. 그럼 잘 돌아가게 짰으니까 잘 처리한 것일까? 이 강의에서는 이 상황에서 정말 많은 질문을 하고 있다.

  • swap함수를 호출을 시켰는데 swap을 해주지 않고 그냥 return을 멋대로 시켜버리는 것이 맞는건가?
  • 애초에 NULL이 들어오는 것 자체가 말이 안되는 것은 아닌가?
  • 철통 방어로 짠 것이니 좋은 거 아닌가?

 

여기서 주목할 부분은 "철통 방어로 짠 것이니 좋은 거 아닌가?" 라는 부분이다. 사실 이론적으로만 말하면 좋은 것이 맞다. 하지만 강의에서는 이것은 사람에 대해 이해하지 못한 것이라고 이야기한다. 그러면서 모든 함수마다 NULL체크를 하는 것은 아래의 예시와 다를 것이 없다고 했다.

놀랍게도 가정집의 두꺼비집을 표현한 것이다.

원래 일반적인 가정집의 두꺼비집이라면 하나의 차단기가 설치되어 있고 전력 공급에 문제가 생기면 차단기가 내려가게끔 되어 있다. 왜 하필 하나인지 생각해본 적 있는가? 만약 위의 그림처럼 되어있다면 어떻게 될까? 엄밀히 말하면 여기저기에서 문제가 생길 수 있기 때문에 이렇게 하면 각 두꺼비집마다 각자 대응을 할 수 있는 것이 맞다! 근데 이해하기도 너무 불편하고 어디에 뭐가 있는지 외우는 것도 너무 귀찮은 것이 사실이다. 결국 이 문제는 아래와 같이 한 군데로 집중을 시키는 것이 더 효율적이다.

이제야 좀 정상적이다.

어떤 사람에게 두꺼비집에 대한 설명서를 준다고 가정해보자. 그 설명서에서 특정 부분을 짚어주면서 "다른 곳은 몰라도 이 부분은 꼼꼼하게 읽으세요." 라고 이야기를 한다면 정말 그 부분은 꼼꼼하게 읽는다. 하지만 전부 꼼꼼하게 읽으라고 하면 그 많은 내용을 읽지는 않는다. 즉, 사람은 집중을 오래하지 못한다. 프로그래밍에서도 마찬가지이다. 그 수많은 코드마다 수많은 매개 변수마다 오류가 있는지 없는지를 일일히 판단하고 검사하는 것은 이론상은 훌륭할지 몰라도 매우 힘든 일이다. 그래서 아래와 같이 이야기한다.

 

"생각없이 무조건 동작한다고 코드를 짜게 될지라도 오류 처리를 할 때 원칙이 있어야 한다."

 

두꺼비집의 예시를 다시 살펴보자면 강의에서는 결국 전선 하나마다 두꺼비집을 설치하는 것과 매개 변수 하나마다 NULL인지 체크하는 행위가 다를 것이 없다는 이야기를 하고 있다. 그리고 그 원칙에 따라 문제를 찾는 곳을 최소화하는 것이 좋다. 이제 올바른 오류 처리를 위한 원칙이 무엇인지 알아보자.

 

 

3. 오류 처리 전략

오류를 처리하는 전략에 대해서 논하기 전에 우리가 흔히 헷갈릴 수 있는 부분을 먼저 짚을 필요가 있다. 바로 버그와 오류의 차이점에 대한 것이다.

  • 오류: 실제 실행중에 일어날 수 있는 예측 가능한 상황들을 의미하며 이는 프로그램에서 대처를 해줘야 한다.
  • 버그: 일어날 수 없다고 가정한 상황을 의미하며 선조건과 후조건이 성립하지 않고 어서트조차 실패하는 상황이다. 이런 상황이면 프로그램이 대처하는 것이 아니라 그냥 그 버그 자체가 나타나지 않도록 코드를 수정해야 한다.

참고로 C89가 어서트를 사용할 때 단점이 있는데 구조체의 크기와 같이 컴파일 중에 알 수 있는 것들도 실행을 해야만 알 수 있다는 것이 있다. C11에서 정적 어서트라는 것을 이용해서 컴파일 중에 오류를 잡을 수 있도록 지원해준다. 아래는 어서트의 예시이다.

unsigned int deposit(unsigned int deposit_amount)
{
    unsigned int before_total;
    unsigned int after_total;
    
    /* 함수의 선조건 */
    assert(deposit_amount > 0);
    
    /* 아주 멋진 코드 */
    
    /* 함수의 후조건 */
    assert(before_total < after_total);
    
    return after_total;
}

위에서 사용한 어서트가 오류의 대표적인 예시이다. 돈을 입금하는 작업을 처리하는 데 있어서 함수가 실행되기 전에 매개 변수로 들어오는 데이터와 함수가 모두 끝난 후에 반환되는 데이터에서 충족되어야 하는 조건과 예측 가능한 예외적인 상황을 코드로 명시한 것이다. 즉, 유효하다고 판단되는 데이터 내에서 선조건과 후조건으로 어서트를 걸어서 예외 처리를 하는 것이다.

 

여기서 실행 중에 오류 처리를 어떻게 해야하는지 정리해보자.

  • 우선 버그는 잡았다고 가정한다. 그럴 수 없는 거 알지만 일단 가정만 한다.
  • 왜 이렇게 하냐면 함수에 들어오는 데이터가 유효하다고 가정하기 위함이다.
  • 만약 이 가정과 맞지 않게 유효하지 않은 데이터가 들어오면 "어딘가" 에서 걸러줘야 한다.
  • 그 "어딘가" 를 경계라고 부른다. 이 경계는 프로그램을 작성하는 프로그래머가 컨트롤을 할 수 있냐 없냐에 대한 경계를 의미한다. 예를 들면 직접 작성한 프로그램은 프로그래머가 컨트롤 할 수 있지만 외부의 입력은 프로그래머가 컨트롤할 수 없는 부분이기 때문에 이 사이를 경계라고 한다는 것이다.

그럼 여기서 아까 전에 다뤘던 swap함수에서 NULL이 들어오는 경우를 어떻게 해야하는지 정리하고 넘어가겠다. 우선 NULL체크는 일일히 하지 않는다. 해당 함수로 들어오는 데이터가 유효하다고 가정했기 때문이다. 만약에 NULL이 들어오는 것이 가능하다면 아래와 같이 명시해야 한다.

monster_t* spawn_monster_or_null(const monster_t* special_monster_or_null)
{
    /* 아주 멋진 코드 */
}

 

"예외 처리 기법 1번: NULL이 들어오거나 반환될 수 있다면 함수나 매개 변수의 이름에서 명시하자."

 

이것 말고도 오류와 관련해서 무언가를 명시하는 경우가 있다. 오류 코드를 반환하는 경우이다. 아래와 같이 뭔가 딱 봐도 오류 코드를 반환하는 것처럼 코드를 짜면 일단 프로그래머 입장에서는 이것을 인지하려고 하기 때문이다. 뭔가 수상한 값을 반환하기 때문에 긴장을 하게 된다는 것이다. 다른 언어처럼 예외를 던지는 방법이 있다면 그런 방식으로 코드를 짜면 되지만 C는 그것이 안되기 때문에 문서를 통해서 어떤 함수가 어떤 경우에 어떤 오류가 발생할 수 있는지 알아야 하는데 문제는 문서를 꼼꼼하게 읽는 사람이 그렇게 많지 않다. 그래서 매개 변수나 반환을 이용해서 처리를 하는 방법이 아주 좋은 방법이 된다.

libabc_error_t try_get_student(int id, student_info_t* out_student)
{
    size_t idx;
    
    /* 아주 멋진 코드 */
    
    if (idx == -1)
    {
        return ERROR_STUDENT_NOTFOUND;
    }
    
    /* 정말 멋진 코드 */
    
    return ERROR_NONE;
}

 

"예외 처리 기법 2번: 필요하다면 반환값이나 매개 변수에서 에러 코드가 존재한다는 것을 명시하자."

 

예외 처리를 하는 데 있어서 enum은 매우 자주 사용되는 수단이다. 그런데 여기서 주의해야 할 것은 다른 언어와 비교했을 때 C는 다른 열거형끼리 대입을 하는 연산이 가능하다는 것이다. 그래서 아래와 같이 다른 열거형끼리 비교 연산을 하면 그게 가능하다! 따라서 함수마다 오류 enum을 만들기 보다는 발생할 수 있는 모든 에러 코드를 하나의 열거형으로 묶는 것이 실수를 더 줄일 수 있는 방법이다.

typedef enum
{
    NAME_ERROR_EMPTY,
    NAME_ERROR_TOO_LONG,
    /* 다른 오류 목록... */
} name_error_t;

typedef enum
{
    ROLE_ERROR_INVALID,
    ROLE_ERROR_FROHIBIDDEN,
    /* 다른 오류 목록... */
} role_error_t;

void do_something(void)
{
    role_error_t role_err;
    
    role_err = get_role(&role);
    
    /* 조건문에서 실수를 했다. */
    if (role_err == NAME_ERROR_EMPTY)
    {
        /* 오류 코드 처리 */
    }
    
    /* 아주 멋진 코드 */
}

 

전에 파일 입출력에서 봤던 errno에 대해서도 다뤘는데 별로 좋은 방법은 아니라고 한다. 이런 방식으로 처리를 하는 것은 문서를 꼼꼼하게 읽어봐야 알 수 있는 부분인데 아까도 말했지만 사람들이 그런 것들을 다 읽으면서 코딩을 하지 않는다. 만약 이것이 외부 라이브러리라면 더더욱 알기가 힘들어질 수 밖에 없다. 그럼 지금까지 다뤘던 모든 내용을 간략하게 정리해보자.

 

  1. 기본적으로 내가 작성하는 모든 함수에 들어오는 데이터는 유효하다고 가정하고 어서트를 많이 쓸 것
  2. 그렇지 않은 함수는 매개 변수나 함수 이름에서 그렇지 않다는 사실을 명백히 표시할 것
  3. 오류 상황을 처리하는 장소는 최소한으로 할 것
  4. 어떤 함수가 오류 처리를 한다는 사실을 반환형 등을 통해 확실히 보여줄 것