참을 수 없는 예외 처리의 편리함

@codemaru · November 22, 2007 · 10 min read

윈도우는 오래 전부터 구조적 예외 처리(SEH, Structured Exception Handling)란 기법을 지원하고 있다. SEH의 사용법에 관해서는 이미 많은 책과 문서에서 소개했기 때문에 대부분의 개발자들이 그 사용법에 대해서 잘 알고 있다. 하지만 예외 처리 코드가 가지는 장점에 대해서 정확하게 이해하지 못해서 의외로 많은 개발자들이 실전에서 SEH나 C++의 예외처리 기능을 활용하지 못하고 있다. 이 글에서는 실패하지 않는 함수를 작성할 때에 SEH 코드가 가지는 장점에 대해서 설명한다.

우리가 오류나 예외란 상황에 접근하는 방법은 크게 두 가지다. 사전 처리와 사후 처리가 그것이다. 사전 처리란 어떠한 작업을 하기 전에 모든 조건이 완벽한지 검사를 하는 것이고, 사후 처리란 일단 작업을 하고 그것이 성공했는지 실패했는지를 살펴보는 것이다. 코드 상에서 사전 처리는 조건 검사를 의미하고 사후 처리는 예외 처리를 의미한다. 간단한 코드를 통해서 두 가지 접근 방법이 가지는 차이를 살펴보도록 하자.

리스트 1 출력용 포인터를 사용하는 함수

void some\_func(DWORD \*some\_ptr)  
{  
    \*some\_ptr = 0x12345678;  
}

<리스트 1>에 나타난 것과 같이 포인터를 인자로 받아서 값을 기록하는 간단한 함수를 생각해보자. 이 코드에서 발생할 수 있는 문제는 무엇일까? <리스트 1>의 코드가 당연해 보인다면 심각한 문제가 있다

리스트 2 ASSERT를 사용한 검사

void some\_func2(DWORD \*some\_ptr)  
{  
    ASSERT(some\_ptr != NULL);  
    \*some\_ptr = 0x12345678;  
}

조금 숙련된 개발자라면 금방 <리스트 2>와 같은 코드를 생각해 낼 것이다. ASSERT는 실행 시간에 버그를 탐지하는데 굉장히 효율적인 방법이다. ASSERT 경고에 관한 활용에 대해서는 참고자료에 있는 “버그 안녕”이란 책을 살펴보도록 하자.

some_func2는 안전해 보이지만 단지 개발단계에서 버그를 탐지할 수 있다는 가능성만을 포함하고 있다. 또한 Release 버전에서는 대부분 제거되도록 매크로를 작성해두기 때문에 배포 버전에서 발생하는 문제에 대해서는 점검을 하기가 쉽지 않다. 그래서 배포 버전에서도 절대 실패해서는 안 되는 함수에 대해서는 적합하지 않은 방법이다.

리스트 3 if문을 통한 검사

void some\_func3(DWORD \*some\_ptr)  
{  
    if(!some\_ptr)  
    {  
        SetLastError(ERROR\_INVALID\_ACCESS);  
        return;  
    }  
      
    \*some\_ptr = 0x12345678;  
}

배포 버전의 안정성까지 생각한다면 <리스트 3>과 같은 코드를 생각해볼 수 있다. 단순히 NULL 체크를 해서 안전한 경우에만 값을 복사하는 것이다. “이제는 끝이겠지?”라고 생각하면 오산이다. 과연 some_ptr에 값을 기록하는 행동이 NULL인 경우를 제외하면 모두 안전할까라는 생각을 해보아야 한다. 현재 프로세스에 맵핑되지 않은 메모리 라던지, 읽기 전용 속성으로 맵핑된 메모리 일수도 있다. 그런 경우에도 여전히 함수는 실패한다.

리스트 4 IsBadWritePtr을 통한 검사

void some\_func4(DWORD \*some\_ptr)  
{  
    if(IsBadWritePtr(some\_ptr, sizeof(\*some\_ptr)))  
    {  
        SetLastError(ERROR\_INVALID\_ACCESS);  
        return;  
    }  
  
    \*some\_ptr = 0x12345678;  
}

이쯤 되면 이제 윈도우 API를 동원할 때가 되었다. <리스트 4>의 코드는 IsBadWritePtr을 통해서 해당 메모리가 쓰기 가능한지를 체크하는 함수다. 해당 API의 구현은 뒤로하더라도 이 정도면 some_ptr에 대한 기록을 하는 행동은 안전하다고 할 수 있다. 이 간단한 작업에도 이렇게 생각할 것이 많은 것이다.

리스트 5 SEH를 사용한 코드

void some\_func5(DWORD \*some\_ptr)  
{  
    \_\_try  
    {  
        \*some\_ptr = 0x12345678;  
    }  
    \_\_except(EXCEPTION\_EXECUTE\_HANDLER)  
    {  
        SetLastError(ERROR\_INVALID\_ACCESS);  
    }      
}

<리스트 4>와 동일한 코드를 SEH로 작성한 것이 <리스트 5>의 코드다. 간단한 코드이기 때문에 코드만으로는 예외처리의 장점을 알기가 쉽지 않다. 단지 명확한 한 가지 단점은 복잡한 조건을 미리 생각하지 않아도 된다는 점이다. __try, __except 블록으로 인해 너무 복잡해 보인다거나, 예외처리 때문에 늦어진다고 불평을 할 수 있다. 하지만 이는 예외 처리에 대한 메커니즘을 정확하게 이해하지 못해서 발생하는 오류다. 우선 __try, __except 블록에 대해서 살펴보자. 분명히 <리스트 4>에는 그러한 것이 없다. 하지만 진짜 없는 것은 아니다. IsBadWritePtr이란 함수 속에 그 블록이 숨겨져 있을 뿐이다. 그렇다면 성능은 어떨까? <리스트 4>의 코드는 오류가 있건 없건 항상 IsBadWritePtr을 호출한다. 반면에 <리스트 5>의 코드는 그런 사전 검사에 드는 비용이 없다. 예외가 발생했을 때만 예외처리 코드가 수행된다. 따라서 <리스트 5>의 코드가 더 효율적이다.

복잡한 사전 조건을 모두 검사할 필요가 없다. 조건 검사 코드가 매번 실행되지 않기 때문에 효율적이다. 정상 코드와 예외 처리 코드가 분리되기 때문에 코드의 가독성이 증가한다. 개발자가 상상도 못했던 진짜 예외가 발생한 경우에도 대처할 수 있다. 큰 것만 생각해도 예외 처리가 가지는 장점은 이렇게 많다. 따라서 복잡한 조건 검사보다는 명확한 예외처리를 사용하는 것이 좋겠다.

SEH와 CRT
생각보다 많은 컴파일러 기능들이 CRT와 연결되어 있다. C++ 예외뿐만 아니라 SEH 또한 CRT 없이는 컴파일을 할 수가 없다. 예외 처리가 포함된 코드를 포함해서 컴파일을 하려고 하면 __except_handler3을 링크할 수 없다는 에러가 난다. Visual Studio에 번들 된 CRT 소스에도 이 함수는 포함되어 있지 않다. 그래서 자칫하면 CRT 없이는 예외처리를 사용할 수 없다고 생각하기 쉽다. 하지만 이는 진실이 아니다. 참고 자료에 있는 Matt Pietrek의 글을 읽으면 알 수 있겠지만 SEH는 근본적으로는 CRT와 상관이 없는 운영체제 기능이다. 컴파일러에서 CRT를 요구하는 이유는 컴파일러가 생성하는 코드에서 사용하는 SEH 필터 함수가 CRT에 포함되어 있기 때문이다.

이 문제를 해결하는 방법으로는 크게 세 가지 정도가 있다. DDK에 포함된 exsup.lib, sehupd.lib을 이용하는 방법, 경량 CRT 라이브러리인 tinycrt를 사용하는 방법, SEH 코드를 직접 구현해서 사용하는 방법이 그것이다.

참고자료

@codemaru
돌아보니 좋은 날도 있었고, 나쁜 날도 있었다. 그런 나의 모든 소소한 일상과 배움을 기록한다. 여기에 기록된 모든 내용은 한 개인의 관점이고 의견이다. 내가 속한 조직과는 1도 상관이 없다.
(C) 2001 YoungJin Shin, 0일째 운영 중