2016년 3월 19일 토요일

[c++기초] 8. 예외처리

32-1.예외

32-1-가.전통적인 예외 처리

예외(Exception)란 프로그램의 정상적인 실행을 방해하는 조건이나 상태를 의미하는데 프로그램을 잘못 작성해서 오동작하거나 다운되게 만드는 에러(Error)와는 다르다. 원칙적으로 에러는 개발중에 모두 수정해야 하는데 모르고 들어가는 것은 할 수 없지만 일단 에러가 있다는 것을 알았으면 그냥 남겨 두지 않을 것이다. 최종 릴리즈할 때까지 미처 발견하지 못하면 이것을 버그라고 부르며 그 중에서도 아주 악질적인 에러를 뻐~그라고 부른다. 예외란 버그와는 달리 제대로 만들었지만 원하는대로 동작하지 못하게 방해하는 외부의 불가항력적인 상황을 말한다.
프로그램을 아무리 치밀하게 논리적으로 잘 작성하더라도 예외는 항상 발생할 수 있는데 왜냐하면 작성 시점에서 실행 시의 모든 상황을 정확하게 예측할 수 없기 때문이다. 예외가 발생하는 가장 큰 이유는 프로그램을 사용하는 사람이라는 존재가 워낙 불확실하기 때문이다. 항상 정해진 절차대로 프로그램을 동작시키고 정확한 값만 입력한다면 문제가 없겠지만 실수 투성이인 사람은 그렇지 못하다.
프로그램이 실행되는 환경 또한 불확실하기는 마찬가지이다. 하드 디스크가 언제 가득찰 지 예측할 수 없으며 프린터의 종이가 언제 떨어질 지도 알 수 없다. 또한 컴퓨터 외부의 환경인 네트워크도 불안정해서 언제든지 끊어질 수 있고 알 수 없는 이유로 데이터가 중간에서 사라지기도 한다. 잘 짜여진 프로그램은 이런 여러 가지 예외 상황에도 잘 대처해야 하는데 잘못된 입력이 왜 잘못되었는지 사용자에게 알리고 다시 입력하도록 해야 하며 실패한 동작은 재시도해야 한다. 어떤 경우라도 최소한 프로그램이 다운되지는 않도록 해야 한다. 예외를 잘 처리하지 못하면 이것도 일종의 버그가 된다.
프로그램은 사용자와 상호 작용하거나 외부의 환경과 통신할 때 항상 방어적인 코드로 발생 가능한 모든 예외를 적절하게 처리해야 한다. 다음 코드는 사용자로부터 두 개의 정수값을 입력받아 두 값을 나눈 결과를 출력한다.

int a,b;

printf("나누어질 수를 입력하시오 : ");
scanf("%d",&a);
printf("나누는 수를 입력하시오 : ");
scanf("%d",&b);
printf("나누기 결과는 %d입니다.\n",a/b);

사용자가 정상적인 값만 입력한다면 이 프로그램은 아무 문제가 없다. printf가 출력하다 실패할리 없고 scanf가 입력을 못 받을리도 없다. 그렇다고 그 정확한 CPU가 그까짓 나눗셈을 틀릴리도 만무하다. a에 6, b에 3을 입력하면 그 결과로 2가 정확하게 출력될 것이다. 그러나 나누어지는 수로 0을 입력하면 이 프로그램은 나눗셈 연산을 하던 중에 예외를 일으키고 다운되어 버린다. 수학적으로 불가능한 연산이므로 b에 절대로 0이 입력되어서는 안된다. 또 a와 b가 좌표값이나 배열의 첨자라고 가정할 때 두 값 모두 음수여서는 안된다는 규칙이 적용된다. 이런 잘못된 입력에 대해 프로그램은 예외를 처리해야 하는데 전통적인 방법은 값을 사용하기 전에 if문으로 입력된 값을 점검하는 것이다.

  : TraditionalError
#include <Turboc.h>

void main()
{
     int a,b;
    
     printf("나누어질 수를 입력하시오 : ");
     scanf("%d",&a);
     if (a < 0) {
          printf("%d는 음수이므로 나누기 거부\n",a);
     } else {
          printf("나누는 수를 입력하시오 : ");
          scanf("%d",&b);
          if (b == 0) {
              puts("0으로는 나눌 수 없습니다.");
          } else if (b < 0) {
              printf("%d는 음수이므로 나누기 거부\n",b);
          } else {
              printf("나누기 결과는 %d입니다.\n",a/b);
          }
     }
}

a를 입력받은 후 if문으로 이 값이 음수인지 점검한다. 만약 음수라면 에러 메시지를 출력하여 잘못된 값임을 알린다. 그렇지 않다면 b를 입력받고 이 값이 0인지, 음수인지를 점검한다. 이 모든 조건이 만족할 때만 a/b 연산 결과를 출력하고 그렇지 않다면 연산을 거부한다. 틀린 값을 입력했을 때 다시 입력받으려면 전체 코드를 while 등의 무한 루프로 감싸고 성공했을 때만 break로 빠져 나오도록 하면 된다.
if문으로 조건을 점검하여 예외를 일으킬만한 상황을 피해가는 이런 전통적인 방법은 지금까지 많이 사용해왔고 또 지극히 상식적인 방법이다. 하지만 점검할 예외가 많아지면 여러 가지로 코드의 품질이 떨어진다. 다음 예는 메모리를 할당하고 값을 입력받아 계산하고 그 결과를 파일로 출력하는 코드인데 발생 가능한 예외가 무척 많다.

size=필요한 메모리 양 조사
if (size < 100M || size > 0) {
     ptr=malloc(size);
     if (ptr) {
          if (InputData(ptr) == TRUE) {
              if (CalcData(ptr) == TRUE) {
                   File=파일 열기();
                   if (File) {
                        if (파일 쓰기()) {
                            에러 출력("파일 쓰기 실패");
                        }
                        파일 닫기();
                   } else {
                        에러 출력("파일을 열 수 없음");
                   }
              } else {
                   에러 출력("계산중 에러 발생");
          } else {
              에러 출력("입력중 에러 발생");
          }
          free(ptr);
     } else {
          에러 출력("메모리 할당 실패");
     }
} else {
     에러 출력("요구하는 메모리 크기가 너무 크거나 황당하게 작음");
}

너무 많은 메모리를 요구하거나 메모리 할당에 실패할 수도 있고 입력 중에 에러가 발생할 수도 있으며 계산 중에 오동작할 수도 있다. 또한 파일을 열거나 쓸 때도 여전히 실패할 가능성이 있다. 이 모든 예외 상황에 대해 일일이 if문으로 조건을 점검해야 하며 그러다 보니 실제로 작업을 하는 코드보다 예외를 판단 및 처리하는 코드가 훨씬 더 많다. 예를 위해 일부러 꼬아 놓은 코드가 아니며 실제 상황은 이보다 훨씬 더 복잡해질 수도 있는데 그만큼 예외는 자주 발생한다.
이런 처리를 하려면 모든 함수는 성공 여부를 리턴해야 하며 함수를 호출하는 곳에서는 리턴값을 일일이 점검해야 하므로 무척 번거롭다. 실제 작업을 하는 코드와 예외를 처리하는 코드가 중간 중간에 섞여 있어 관리하기도 어렵고 코드의 핵심을 파악하기도 쉽지 않다. 에러를 점검하는 if문과 에러 메시지 출력문이 너무 떨어져서 대응되는 코드를 한눈에 알아보기도 어렵다. 게다가 잦은 if문으로 인해 들여쓰기가 지나치게 깊어져 코드의 모양이 꼴사납다. 이순신 장군의 학익진과 유사한 모양인데 싸울 때는 좋을지 몰라도 코드를 관리할 때는 별로 좋지 않다.
이런 코드를 분석할 때는 잘 발생하지 않는 예외 코드는 일단 무시하고 읽어야 하는데 무질서하게 섞여 있다 보니 어디가 예외 처리 코드인지 어디가 진짜 코드인지 잘 분간되지도 않는다. 안정적인 프로그램을 만들기 위해서는 발생 가능한 모든 예외를 처리할 필요가 분명히 있다. 그러나 전통적인 방법은 여러 가지로 좋지 않은 효과가 있어 질적으로 다른 방법이 필요해졌다.


32-1-나.C++의 예외 처리

예외 처리는 튼튼한 프로그램을 만들기 위해 어차피 필요하되 형식성을 좀 갖출 필요가 있다. 이런 필요성은 아주 오래 전부터 인식되어 왔고 그동안 많은 예외 처리 방법들이 개발되었다. 전통적인 C 라이브러리도 setjmp, longjmp라는 함수가 있고 윈도우즈는 운영체제 차원에서 구조적인 예외 처리 기법(SEH)을 제공하며 이 기법은 비주얼 C++ 컴파일러에 의해 구현되었다. 또한 MFC 라이브러리는 예외를 처리하는 CException과 파생 클래스를 제공하기도 한다.
C++은 함수나 컴파일러, 라이브러리 수준이 아닌 언어 차원에서 새로운 예외 처리 문법을 제공한다. 언어가 제공하는 기능이기 때문에 기존 방법에 비해 좀 더 유연하고 클래스를 인식하므로 적절한 시점에 파괴자를 호출하여 깔끔하게 예외를 처리할 수 있다. C++의 예외 처리 문법은 다음 세 키워드로 지원된다.

■ try : 예외가 발생할만한 코드 블록을 지정하는데 try 다음의 { } 괄호안에 예외 처리 대상 코드를 작성한다. 이 블록 안에서 예외가 발생했을 때 throw 명령으로 예외를 던진다.
■ throw : 프로그램이 정상적으로 실행될 수 없는 상황일 때 이 명령으로 예외를 던진다. throw 다음에 던지고자 하는 예외를 적는다. 예외를 던진다는 것은 예외가 발생되었다는 것을 알리며 이 예외를 처리하는 catch문으로 점프하도록 한다. throw 명령 아래쪽의 코드들은 모두 무시되며 곧바로 예외 처리 구문으로 이동한다.
■ catch : try 블록 다음에 이어지며 던져진 예외를 받아서 처리한다. 그래서 catch 블록을 예외 핸들러라고 부른다. catch 다음에는 받고자 하는 예외의 타입을 적는데 이 객체는 throw에 의해 던져진다. catch 블록에는 예외를 처리하는 코드가 작성된다.

가장 간단한 예외 처리 구문의 예는 다음과 같다.

try {
     if (예외 조건) throw 예외 객체;
}
catch (예외 객체) {
     예외 처리
}

try 블록 안에서 어떤 연산을 하는데 연산중에 에러가 발생하면 throw로 예외를 던지며 catch가 이 예외를 받아 처리한다. try블록과 catch는 한 쌍이므로 반드시 연속적으로 배치되어야 하며 중간에 다른 문장이 끼어들어서는 안된다. 다음 예제는 좌표와 숫자를 입력받아 지정한 좌표에 숫자의 제곱근을 출력하는데 전통적인 방법으로 예외를 처리했다.

  : ifexcept
#include <Turboc.h>
#include <math.h>

void main()
{
     int x,y,r;

     printf("x 좌표 입력 : ");scanf("%d",&x);
     if (x < 0) {
          printf("%d는 음수이므로 잘못된 값입니다.\n",x);
          exit(-1);
     }
     printf("y 좌표 입력 : ");scanf("%d",&y);
     if (y < 0) {
          printf("%d는 음수이므로 잘못된 값입니다.\n",y);
          exit(-1);
     }
     printf("숫자 입력 : ");scanf("%d",&r);
     if (r < 0) {
          printf("%d는 음수이므로 잘못된 값입니다.\n",r);
          exit(-1);
     }

     gotoxy(x,y);
     printf("%d의 제곱근은 %.4f입니다\n",r,sqrt(r));
}

화면상의 좌표는 당연히 양수여야 한다. 그리고 음수의 제곱근은 존재하지 않으므로 반드시 양수값만 입력받아야 한다. 이런 규칙을 점검하기 위해 매 값을 입력받을 때마다 if문으로 값의 부호를 점검하고 음수가 입력되었을 때는 에러 메시지를 출력한 후 프로그램을 종료하도록 했다. 프로그램의 안전성을 위해 꼭 필요한 에러 처리이기는 하지만 똑같은 코드가 계속 반복되어 용량을 낭비하고 있다. 또한 프로그램의 고유 코드보다 에러를 처리하는 코드가 더 길어 보기에도 좋지 않다. 이 프로그램을 C++의 예외 처리 구문으로 바꾸면 다음과 같이 정리할 수 있다.

  : trycatch
#include <Turboc.h>
#include <math.h>

void main()
{
     int x,y,r;

     try {
          printf("x 좌표 입력 : ");scanf("%d",&x);
          if (x < 0) throw x;
          printf("y 좌표 입력 : ");scanf("%d",&y);
          if (y < 0) throw y;
          printf("숫자 입력 : ");scanf("%d",&r);
          if (r < 0) throw r;
     }
     catch(int a) {
          printf("%d는 음수이므로 잘못된 값입니다.\n",a);
          exit(-1);
     }

     gotoxy(x,y);
     printf("%d의 제곱근은 %.4f입니다\n",r,sqrt(r));
}

예외가 발생할 가능성이 있는 입력문들을 모두 try로 둘러싸고 try 블록 안에서 잘못된 값이 입력될 때마다 throw로 입력된 정수값을 던지기만 했다. throw가 에러를 유발시킨 정수를 던지면 try 블록 다음의 catch에서 이 정수를 받아 에러 메시지를 출력하고 exit(-1)로(또는 return으로) 프로그램을 종료한다. 이때 try블록의 throw 아래에 있는 코드는 무시되는데 한 값이 잘못 입력되었으면 다음 값은 입력받을 필요가 없기 때문이다. 순차적으로 실행되는 코드 흐름에서 앞 부분이 잘못되면 일반적으로 뒷 부분의 코드도 제대로 동작하지 않는다.
만약 예외가 발생하지 않으면 catch 블록에 있는 예외 처리 코드는 실행되지 않고 무시된다. 모든 값이 다 양수로 입력되었다면 예외가 발생하지 않으며 이때는 (x, y) 위치에 sqrt(r) 값을 정상적으로 출력할 수 있다. 똑같이 반복되는 에러 처리 코드를 한 곳에 모을 수 있어 코드가 짧아지며 에러 처리 구문과 고유의 처리 코드가 분리되어서 읽기에도 좋다.
catch 블록 안의 코드는 예외가 발생할 때만 실행되며 오로지 throw에 의해서만 이동 가능하다.  아무리 잘못된 문장이라도 에외가 자동으로 발생하는 법은 없으므로 throw로 던질 때만 예외가 발생하며 catch블록은 예외가 발생할 때만 호출된다. goto나 return 기타 제어를 옮기는 명령으로는 catch 안으로 이동하지 못하며 반드시 throw로만 제어를 옮길 수 있다. 반면 catch문 안에서는 goto, return, break, continue 등의 명령들로 블록 밖으로 이동할 수 있다.
catch의 코드는 잘 발생하지 않는 비정상적인 상황을 처리하는 것이므로 프로그램의 논리와는 큰 상관이 없다. 예를 들어 하드 디스크가 가득 찼다거나 네트워크 카드가 갑작스럽게 고장난 상황을 들 수 있는데 발생 빈도가 지극히 낮기는 하지만 그렇다고 처리하지 않을 수는 없다. catch가 처리하는 예외는 극단적인 상황에 대한 대책이므로 이런 코드를 분석할 때는 무시하고 읽어도 상관없다. 오히려 그러는 편이 코드를 빨리 읽는 방법이며 이를 위해 예외 처리 코드를 분리하는 문법이 제공되는 것이다.
하나의 try 블록에서 타입이 다른 여러 개의 예외를 발생시킬 수도 있는데 이때는 예외의 타입수만큼의 catch를 try 블록 다음에 나열하면 된다. 각 catch 문들은 모두 try와 한 덩어리이므로 catch문 사이에도 다른 문장이 끼어들어서는 안된다. 다음 예제는 두 정수를 입력받아 나누기 연산을 하는데 피젯수는 반드시 양수여야 한다고 가정하도록 하자.

  : multicatch
#include <Turboc.h>

void main()
{
     int a,b;

     try {
          printf("나누어질 수를 입력하시오 : ");
          scanf("%d",&a);
          if (a < 0) throw a;
          printf("나누는 수를 입력하시오 : ");
          scanf("%d",&b);
          if (b == 0) throw "0으로는 나눌 수 없습니다.";
          printf("나누기 결과는 %d입니다.\n",a/b);
     }
     catch(int a) {
          printf("%d는 음수이므로 나누기 거부\n",a);
     }
     catch(const char *message) {
          puts(message);
     }
}

try 블록에서 a를 먼저 입력받고 이 값이 음수일 경우 정수값 a를 던진다. 이때 throw는 정수값을 받는 catch를 찾아 점프하며 catch는 이 값이 음수이므로 연산을 할 수 없다는 에러 메시지를 출력한다. a가 양수일 경우 다음 문장에서 b를 입력받는데 b가 0일 경우 나눗셈을 할 수 없다는 문자열 예외를 던진다. 이때는 char *를 받는 catch문으로 점프하여 문자열을 메시지로 출력한다.
a와 b가 모두 정상적으로 입력되면 a/b 연산 결과를 출력하고 뒤쪽의 catch문은 무시된다. throw가 던지고 catch가 받는 것을 예외 객체라고 하는데 throw가 던지는 실인수가 catch의 형식 인수로 대입된다고 생각하면 된다. catch는 전달된 예외 객체를 통해 에러의 내용을 파악하고 에러를 어떻게 처리할 것인지를 결정한다.
catch는 마치 throw에 의해 호출되는 함수에 비유될 수 있으며 함수가 오버로딩될 수 있듯이 catch도 여러 가지 예외 타입에 따라 오버로딩될 수 있다. throw가 던지는 예외의 타입과 일치하는 catch가 호출되는 것이다. 필요하다면 catch내에서 지역변수를 선언해서 사용할 수도 있다. 물론 이는 어디까지나 비유일 뿐 catch가 진짜 함수라는 얘기는 아니다. catch로 이동하면 다시 리턴하지 않으므로 throw에 의해 점프되는 레이블이라고 보는 편이 더 타당하다. throw는 호출(call)이 아니라 무조건 분기문인 goto와 더 가깝다.


32-1-다.함수와 예외 처리

예외를 던지는 throw는 보통 try 블록 내부에 있어야 한다. 그러나 함수 안에서는 try 블록없이 throw만 있을 수도 있다. 이때는 함수를 호출하는 호출원이 try 블록을 가져야 한다. 다음 예제는 0으로 나누는 함수 divide를 작성하고 이 함수에서 인수로 전달된 d가 0일 때 throw로 예외를 던진다. main에서 4번 throw를 호출하는데 각 경우에 어떻게 처리되는지 보자.

  : throwfunc
#include <Turboc.h>

void divide(int a, int d)
{
     if (d == 0) throw "0으로는 나눌 수 없습니다.";
     printf("나누기 결과 = %d입니다.\n",a/d);
}

void main()
{
     try {
          divide(10,0);
     }
     catch(const char *message) {
          puts(message);
     }
     divide(10,5);
//  divide(2,0);
/*
     try {
          divide(20,0);
     }
     catch(int code) {
          printf("%d번 에러가 발생했습니다.\n",code);
     }
//*/
}

함수 실행중에 throw를 만나면 대응되는 catch를 찾기 위해 자신을 호출한 호출원을 거슬러 올라가야 한다. 첫 번째 divide 호출문에서 예외가 발생하면 divide 함수는 자신을 호출한 main으로 돌아와서 대응되는 catch문을 찾아 이 코드를 실행한다. catch는 throw가 던진 에러 메시지 문자열을 화면으로 그대로 출력할 것이다. 만약 main과 divide 사이에 다른 함수들이 있더라도 마찬가지로 main까지 복귀한 후 예외가 처리된다.
함수가 호출될 때는 스택에 각 함수의 스택 프레임이 생성되며 스택 프레임에는 함수 실행에 필요한 여러 가지 정보들이 저장된다. 함수가 리턴할 때 스택 프레임은 정확하게 호출 전의 상태로 돌아가도록 되어 있다. 예외가 발생했을 때 호출원의 catch로 곧바로 점프해 버리면 스택이 항상성을 잃어 버리므로 이후 프로그램이 제대로 실행될 수 없을 것이다. 그래서 throw는 호출원으로 돌아가기 전에 자신과 자신을 호출한 함수의 스택을 모두 정리하고 돌아가는데 이를 스택 되감기(Stack Unwinding)라고 한다.
첫 번째 divide 호출문이 예외를 던질 때 main의 catch가 이 예외를 처리한 후 그 다음 문장을 아무 이상없이 실행할 수 있는 이유는 throw가 스택 되감기를 하여 main의 스택 프레임을 divide 호출 전의 상태로 복구하기 때문이다. 두 번째 divide(10,5)는 올바른 인수를 전달했으므로 예외가 발생되지 않으며 호출 후 정상적인 절차대로 리턴한다.
세 번째 divide(2,0) 호출은 두 번째 인수가 0이므로 예외가 발생하는데 이때 이 예외를 받아줄 catch문이 없다. 함수 호출부가 try 블록에 있지 않기 때문인데 이때는 예외를 처리할 수 없으므로 디폴트 처리되어 프로그램이 강제로 종료된다. 설사 try안에 있더라도 예외를 받아줄 catch가 없으면 이때도 처리되지 않는데 네 번째 호출문 divide(20,0)의 경우 try안에 있고 catch도 있지만 divide가 던지는 char * 타입의 catch는 없으므로 역시 처리되지 않고 프로그램은 종료된다.
throw는 대응되는 try 블록의 catch를 찾기 위해 스택에서 위쪽 함수를 찾아 올라가면서 호출 스택을 차례대로 정리하는데 이때 각 함수들이 지역적으로 선언한 객체들도 정상적으로 파괴된다. 다음 예제를 통해 스택을 되감는 절차를 연구해 보자.

  : stackunwinding
#include <Turboc.h>

class C
{
     int a;
public:
     C() { puts("생성자 호출"); }
     ~C() { puts("파괴자 호출"); }
};

void divide(int a, int d)
{
     if (d == 0) throw "0으로는 나눌 수 없습니다.";
     printf("나누기 결과 = %d입니다.\n",a/d);
}

void calc(int t,const char *m)
{
     C c;
     divide(10,0);
}

void main()
{
     try {
          calc(1,"계산");
     }
     catch(const char *message) {
          puts(message);
     }
     puts("프로그램이 종료됩니다.");
}

main의 try 블록에서 calc를 부르고 calc는 지역 객체 C를 선언한다. 그리고 예외를 일으키는 divide(10,0)을 호출하는데 이 함수에서 throw에 의해 문자열 예외가 던져진다. 이 때의 스택 상황은 다음과 같을 것이다.
divide에서 예외가 발생했으므로 이 함수는 더 이상 실행할 수 없다. 그래서 이 예외를 처리할 catch문을 찾는데 함수 내부에서는 catch가 없으므로 일단 자신을 호출한 calc 함수로 돌아간다. 이 과정에서 자신의 스택 프레임은 정리하는데 이렇게 하지 않으면 호출원이 예외를 처리하더라도 제대로 실행될 수 없기 때문이다.
calc에서 다시 catch를 찾는데 이 함수도 catch를 가지고 있지 않으므로 같은 방식으로 스택을 정리한다. 이때 calc의 인수 t와 m, 지역변수 C가 파괴되는데 C는 객체이므로 정상적인 파괴를 위해 파괴자가 호출된다. calc가 main으로 리턴하면 main의 catch(char *)로 점프하여 예외를 처리한다. 스택 되감기를 하면서 리턴되는 함수의 모든 지역 객체를 파괴하는데 만약 파괴자를 호출하지 않는다면 예외만 처리될 뿐 생성된 객체들이 제대로 해제되지 않아 프로그램의 상태는 여전히 불안해질 것이다. 파괴자는 단순히 메모리만 정리하는 것이 아니라 때로는 DB 연결 해제, 프로그램 상태 변경 등의 중요한 일을 할 수도 있으므로 반드시 호출해야 한다.
throw가 대응되는 catch를 찾기 위해 스택 되감기를 해야 하는 이유는 아주 명백하다. throw는 catch로의 점프 동작을 하는데 함수간에 아무렇게나 점프를 해 버리면 스택의 호출 정보는 엉망이 되어 버린다. 호출원으로 돌아갈 때는 스택도 호출원의 것으로 정확하게 복구해야 하며 그러기 위해서는 자신을 호출한 모든 함수의 스택을 일일이 정리해야 하는 것이다. 위 예에서 main의 마지막에 있는 puts가 제대로 실행되려면 catch가 예외를 처리한 후 스택의 최상단에는 main의 스택 프레임이 있어야 하며 그러기 위해서는 divide의 throw가 divide와 calc의 스택을 정리해야 하는 것이다.


2-1-다.함수와 예외 처리

예외를 던지는 throw는 보통 try 블록 내부에 있어야 한다. 그러나 함수 안에서는 try 블록없이 throw만 있을 수도 있다. 이때는 함수를 호출하는 호출원이 try 블록을 가져야 한다. 다음 예제는 0으로 나누는 함수 divide를 작성하고 이 함수에서 인수로 전달된 d가 0일 때 throw로 예외를 던진다. main에서 4번 throw를 호출하는데 각 경우에 어떻게 처리되는지 보자.

  : throwfunc
#include <Turboc.h>

void divide(int a, int d)
{
     if (d == 0) throw "0으로는 나눌 수 없습니다.";
     printf("나누기 결과 = %d입니다.\n",a/d);
}

void main()
{
     try {
          divide(10,0);
     }
     catch(const char *message) {
          puts(message);
     }
     divide(10,5);
//  divide(2,0);
/*
     try {
          divide(20,0);
     }
     catch(int code) {
          printf("%d번 에러가 발생했습니다.\n",code);
     }
//*/
}

함수 실행중에 throw를 만나면 대응되는 catch를 찾기 위해 자신을 호출한 호출원을 거슬러 올라가야 한다. 첫 번째 divide 호출문에서 예외가 발생하면 divide 함수는 자신을 호출한 main으로 돌아와서 대응되는 catch문을 찾아 이 코드를 실행한다. catch는 throw가 던진 에러 메시지 문자열을 화면으로 그대로 출력할 것이다. 만약 main과 divide 사이에 다른 함수들이 있더라도 마찬가지로 main까지 복귀한 후 예외가 처리된다.
함수가 호출될 때는 스택에 각 함수의 스택 프레임이 생성되며 스택 프레임에는 함수 실행에 필요한 여러 가지 정보들이 저장된다. 함수가 리턴할 때 스택 프레임은 정확하게 호출 전의 상태로 돌아가도록 되어 있다. 예외가 발생했을 때 호출원의 catch로 곧바로 점프해 버리면 스택이 항상성을 잃어 버리므로 이후 프로그램이 제대로 실행될 수 없을 것이다. 그래서 throw는 호출원으로 돌아가기 전에 자신과 자신을 호출한 함수의 스택을 모두 정리하고 돌아가는데 이를 스택 되감기(Stack Unwinding)라고 한다.
첫 번째 divide 호출문이 예외를 던질 때 main의 catch가 이 예외를 처리한 후 그 다음 문장을 아무 이상없이 실행할 수 있는 이유는 throw가 스택 되감기를 하여 main의 스택 프레임을 divide 호출 전의 상태로 복구하기 때문이다. 두 번째 divide(10,5)는 올바른 인수를 전달했으므로 예외가 발생되지 않으며 호출 후 정상적인 절차대로 리턴한다.
세 번째 divide(2,0) 호출은 두 번째 인수가 0이므로 예외가 발생하는데 이때 이 예외를 받아줄 catch문이 없다. 함수 호출부가 try 블록에 있지 않기 때문인데 이때는 예외를 처리할 수 없으므로 디폴트 처리되어 프로그램이 강제로 종료된다. 설사 try안에 있더라도 예외를 받아줄 catch가 없으면 이때도 처리되지 않는데 네 번째 호출문 divide(20,0)의 경우 try안에 있고 catch도 있지만 divide가 던지는 char * 타입의 catch는 없으므로 역시 처리되지 않고 프로그램은 종료된다.
throw는 대응되는 try 블록의 catch를 찾기 위해 스택에서 위쪽 함수를 찾아 올라가면서 호출 스택을 차례대로 정리하는데 이때 각 함수들이 지역적으로 선언한 객체들도 정상적으로 파괴된다. 다음 예제를 통해 스택을 되감는 절차를 연구해 보자.

  : stackunwinding
#include <Turboc.h>

class C
{
     int a;
public:
     C() { puts("생성자 호출"); }
     ~C() { puts("파괴자 호출"); }
};

void divide(int a, int d)
{
     if (d == 0) throw "0으로는 나눌 수 없습니다.";
     printf("나누기 결과 = %d입니다.\n",a/d);
}

void calc(int t,const char *m)
{
     C c;
     divide(10,0);
}

void main()
{
     try {
          calc(1,"계산");
     }
     catch(const char *message) {
          puts(message);
     }
     puts("프로그램이 종료됩니다.");
}

main의 try 블록에서 calc를 부르고 calc는 지역 객체 C를 선언한다. 그리고 예외를 일으키는 divide(10,0)을 호출하는데 이 함수에서 throw에 의해 문자열 예외가 던져진다. 이 때의 스택 상황은 다음과 같을 것이다.
divide에서 예외가 발생했으므로 이 함수는 더 이상 실행할 수 없다. 그래서 이 예외를 처리할 catch문을 찾는데 함수 내부에서는 catch가 없으므로 일단 자신을 호출한 calc 함수로 돌아간다. 이 과정에서 자신의 스택 프레임은 정리하는데 이렇게 하지 않으면 호출원이 예외를 처리하더라도 제대로 실행될 수 없기 때문이다.
calc에서 다시 catch를 찾는데 이 함수도 catch를 가지고 있지 않으므로 같은 방식으로 스택을 정리한다. 이때 calc의 인수 t와 m, 지역변수 C가 파괴되는데 C는 객체이므로 정상적인 파괴를 위해 파괴자가 호출된다. calc가 main으로 리턴하면 main의 catch(char *)로 점프하여 예외를 처리한다. 스택 되감기를 하면서 리턴되는 함수의 모든 지역 객체를 파괴하는데 만약 파괴자를 호출하지 않는다면 예외만 처리될 뿐 생성된 객체들이 제대로 해제되지 않아 프로그램의 상태는 여전히 불안해질 것이다. 파괴자는 단순히 메모리만 정리하는 것이 아니라 때로는 DB 연결 해제, 프로그램 상태 변경 등의 중요한 일을 할 수도 있으므로 반드시 호출해야 한다.
throw가 대응되는 catch를 찾기 위해 스택 되감기를 해야 하는 이유는 아주 명백하다. throw는 catch로의 점프 동작을 하는데 함수간에 아무렇게나 점프를 해 버리면 스택의 호출 정보는 엉망이 되어 버린다. 호출원으로 돌아갈 때는 스택도 호출원의 것으로 정확하게 복구해야 하며 그러기 위해서는 자신을 호출한 모든 함수의 스택을 일일이 정리해야 하는 것이다. 위 예에서 main의 마지막에 있는 puts가 제대로 실행되려면 catch가 예외를 처리한 후 스택의 최상단에는 main의 스택 프레임이 있어야 하며 그러기 위해서는 divide의 throw가 divide와 calc의 스택을 정리해야 하는 것이다.


32-1-라.중첩 예외 처리

예외 처리 구문은 중첩 가능하다. 즉 try 블록안에 또 다른 try 블록이 있을 수 있으며 중첩 단계에는 별다른 제약이 없다. 다음 예제는 학번, 이름, 나이를 입력받아 그대로 출력하는데 학번과 나이는 반드시 양수여야 하며 이름은 최소한 4자 이상이어야 한다. 한국 사람 이름은 최소한 2글자 이상이므로 아무리 짧아도 4바이트 이상이어야 한다는 규칙은 아주 자연스럽다.

  : nesttry
#include <Turboc.h>

void main()
{
     int Num;
     int Age;
     char Name[128];

     try {
          printf("학번을 입력하시오 : ");
          scanf("%d",&Num);
          fflush(stdin);
          if (Num <= 0) throw Num;
          try {
              printf("이름을 입력하시오 : ");
              gets(Name);
              if (strlen(Name) < 4) throw "이름이 너무 짧습니다";
              printf("나이를 입력하시오 : ");
              scanf("%d",&Age);
              if (Age <= 0) throw Age;
              printf("입력한 정보 => 학번:%d, 이름:%s, 나이:%d\n",Num,Name,Age);
          }
          catch(const char *Message) {
              puts(Message);
          }
          catch(int) {
              throw;
          }
     }
     catch(int n) {
          printf("%d는 음수이므로 적합하지 않습니다.\n",n);
     }
}

최초 바깥쪽의 try 블록에서 학번 Num을 입력받는데 이 값이 음수(0도 포함)일 경우 잘못된 값이므로 Num을 예외로 던진다. 이 예외는 바깥쪽의 catch(int n)이 받아 처리할 것이다. 학번이 제대로 입력되었을 경우 이름과 나이를 입력받는데 이름 길이가 4보다 작을 경우 문자열로 된 예외를 던진다. 이 예외는 안쪽의 catch(const char *)가 받아서 처리한다.
나이가 음수일 경우는 안쪽의 catch(int)로 예외를 던진다. 이때 안쪽에서 정수형 예외를 처리하기에 부적당하다거나 아니면 이미 바깥쪽에서 같은 종류의 예외를 처리하고 있다면 안쪽의 catch에서는 이 예외를 직접 처리하지 않고 바깥쪽의 예외 처리기에게 넘기는 것이 더 편리하다. catch 블록에서 예외를 다시 던질 때는 예외 객체를 지정할 필요없이 throw 명령만 단독으로 사용한다. 받은 객체를 그대로 다시 넘기는 것이므로 예외 객체를 명시할 필요가 없으며 직접 처리하지 않으므로 catch의 괄호안에 예외 객체의 이름을 줄 필요도 없다.
catch에서 바깥쪽 catch로 점프할 때는 throw 명령만 단독으로 사용하는데 만약 바깥쪽에 적절한 catch가 없으면 이 예외는 디폴트 처리되어 프로그램이 강제 종료된다. 이 예제는 한 함수안에서 try를 중첩시켜 다소 억지스러운 면이 있는데 예외를 던지는 함수끼리 서로 호출하다 보면 예외 처리 블록을 중첩해야 하는 경우가 있다.


32-2.예외 객체

32-2-가.예외를 전달하는 방법

함수가 어떤 연산을 하던 중에 프로그램을 정상적으로 실행할 수 없는 에러가 발생했을 때 함수는 에러가 발생했다는 사실 뿐만 아니라 어떤 종류의 에러가 왜 발생했는지 상세한 정보를 전달해야 한다. 그래야 호출원에서 에러의 종류에 따라 다음 동작을 결정할 수 있을 것이다. 전통적인 방법은 에러를 의미하는 정수값을 리턴하는 것이다. 다음 예제의 Calc 함수는 어떤 유용한 계산을 하는 함수인데 실행중에 에러가 발생했다고 가정하자.

  : ExceptionReturn
#include <Turboc.h>

int Calc()
{
     // 메모리 할당 후 연산해서 파일로 출력하는 동작을 한다고 하자.

     if (TRUE/*예외 발생*/) return 1;

     // 여기까지 왔으면 무사히 작업 완료했음
     return 0;
}

void main()
{
     int e;

     e=Calc();
     switch (e) {
     case 1:
          puts("메모리가 부족합니다.");
          break;
     case 2:
          puts("연산 범위를 초과했습니다.");
          break;
     case 3:
          puts("하드 디스크가 가득 찼습니다.");
          break;
     default:
          puts("작업을 완료했습니다."); 
          break;
     }
}

Calc는 정상적으로 계산이 완료되었을 때 0을 리턴하며 에러가 발생했을 때 1~3사이의 에러 코드를 리턴한다. Calc를 호출하는 호출원에서는 이 함수의 리턴값을 점검하여 0인지 아닌지를 반드시 살펴보고 에러가 발생했을 때 이를 적극적으로 처리해야 한다. 가령 입력값이 잘못되었다면 다시 입력받아야 하고 계산에 필요한 데이터가 없다면 이 데이터를 준비한 후 Calc를 다시 불러야 할 것이다.
정수형의 특정한 에러 코드를 넘기는 방식은 지금까지 많이 사용해 왔던 방식이기는 하나 에러 코드가 정상적인 리턴값과 반드시 구분되어야 하는 조건이 있다. 함수가 양수만 리턴할 수 있다면 -1 등의 특이값을 에러 표식으로 사용할 수 있지만 그렇지 않은 경우는 마땅히 에러로 넘길만한 특이값을 선정하기가 무척 어렵다. 이런 경우는 참조 호출로 별도의 BOOL형 변수를 넘겨 에러 여부를 리턴하는 불편한 방법을 사용해야 했었다.
이 함수의 에러 처리 방식을 C++의 예외 처리 구문으로 바꿔 보자. 에러를 리턴값으로 넘기지 않으므로 함수는 void형이어도 상관없으며 리턴값을 다른 용도로 사용할 수도 있다. Calc는 실행중에 에러가 발생하면 throw로 예외를 던지기만 한다. throw 자체가 함수를 종료하므로 별도의 return문을 사용할 필요는 없다. 예외의 타입으로 정수를 쓸 수도 있지만 사람이 에러 코드를 일일이 기억해야 한다는 면에서 불편하다. 그래서 정수형의 예외를 던지는 것보다는 열거형의 예외를 던지는 편이 더 편리하다.

  : ExceptionEnum
#include <Turboc.h>

enum E_Error { OUTOFMEMORY, OVERRANGE, HARDFULL };
void Calc()throw(E_Error)
{
     // 메모리 할당 후 연산해서 파일로 출력하는 동작을 한다고 하자.

     if (TRUE/*예외 발생*/) throw OVERRANGE;

     // 여기까지 왔으면 무사히 작업 완료했음
}

void main()
{
     try {
          Calc();
          puts("작업을 완료했습니다.");
     }
     catch(E_Error e) {
          switch (e) {
          case OUTOFMEMORY:
              puts("메모리가 부족합니다.");
              break;
          case OVERRANGE:
              puts("연산 범위를 초과했습니다.");
              break;
          case HARDFULL:
              puts("하드 디스크가 가득 찼습니다.");
              break;
          }
     }
}

Calc 함수가 예외를 던지므로 main은 이 함수를 호출하는 문장을 반드시 try 블록에 작성하고 try 블록 다음에는 Calc가 던지는 예외를 처리하는 catch 블록이 이어진다. catch는 Calc가 던진 열거형의 에러 코드를 e로 받아 e값에 따라 다양한 방식으로 에러를 처리할 수 있다. 이 예제는 단순히 문자열만 출력해서 에러 발생 사실만 알린다.
열거형의 에러 값은 정수형보다 의미가 좀 더 분명하다는 면에서 사용하기 쉽다. 그러나 호출원에서 에러의 의미를 일일이 기억하고 해석해야 한다는 점에 있어서는 여전히 불편하다. 아예 예외를 일으키는 쪽에서 예외의 의미까지도 전달하도록 바꿔 보자. throw로 던질 수 있는 예외 객체의 타입에는 제한이 없으므로 문자열을 포함하는 구조체를 던진다면 에러 메시지를 구조체에 포함시킬 수 있을 것이다.
구조체보다 더 좋은 방법은 예외와 관련된 동작까지도 처리할 수 있도록 예외를 클래스로 정의하는 것이다. throw는 예외 클래스의 임시 객체를 만들어서 던질 수 있으며 catch는 이 예외 객체로부터 예외에 대한 상세한 정보는 물론이고 예외 객체가 스스로 예외를 처리하도록 할 수 있다. 다음과 같이 수정해 보자.

  : ExceptionObject
#include <Turboc.h>

class Exception
{
private:
     int ErrorCode;

public:
     Exception(int ae) : ErrorCode(ae) { }
     int GetErrorCode() { return ErrorCode; }
     void ReportError() {
          switch (ErrorCode) {
          case 1:
              puts("메모리가 부족합니다.");
              break;
          case 2:
              puts("연산 범위를 초과했습니다.");
              break;
          case 3:
              puts("하드 디스크가 가득 찼습니다.");
              break;
          }
     }
};

void Calc()
{
     // 메모리 할당 후 연산해서 파일로 출력하는 동작을 한다고 하자.

     if (TRUE/*에러 발생*/) throw Exception(1);

     // 여기까지 왔으면 무사히 작업 완료했음
}


void main()
{
     try {
          Calc();
          puts("작업을 완료했습니다.");
     }
     catch(Exception &e) {
          printf("에러 코드 = %d => ",e.GetErrorCode());
          e.ReportError();
     }
}

Exception이라는 예외 클래스를 먼저 정의하는데 이 클래스안에는 에러 코드값을 가지는 멤버와 생성자, 에러 코드를 조사하는 함수, 에러 메시지를 출력하는 함수가 포함되어 있다. 에러에 대한 모든 처리를 클래스 하나에 작성해 놓는 것이다.
Calc 함수는 에러가 발생했을 때 에러에 대응되는 예외 객체를 생성하여 이 객체를 throw로 던지고 catch는 예외 객체의 레퍼런스를 받아 예외 객체로부터 에러 코드를 얻고 에러 메시지 출력을 예외 객체에게 시킨다. 예외 클래스는 필요한 정보는 물론이고 동작까지 완벽하게 정의할 수 있으므로 한 번 잘 만들어 놓으면 사용하기 무척 쉽고 원한다면 재사용도 가능하다. 그래서 throw가 던지는 예외에 대한 정보를 예외 객체라고 부르는 것이다. 정수형이나 문자열 등의 단순한 값을 던질 수도 있지만 어차피 정수나 문자열도 객체이므로 예외 객체라는 표현은 전혀 틀리지 않다.
throw는 던지는 예외 객체의 복사본을 생성하고 이 복사본을 던진다. 이때 throw가 던지는 객체는 임시 객체일 뿐이므로 new Exception으로 동적 생성할 필요가 없다. catch에서 이 객체를 잡을 때는 가급적이면 레퍼런스로 잡는 것이 좋다. 물론 레퍼런스가 아닌 객체 자체를 값으로 받거나 포인터로 받아도 잘 동작한다. 그러나 알다시피 객체는 크기 때문에 값으로 받으면 전달 속도가 느리다는 단점이 있다. 포인터를 쓰면 . 연산자 대신 ->를 사용해야 하므로 쓰는 쪽에서 불편할 뿐만 아니라 예외를 던질 때도 throw &Exception(1); 과 같이 & 연산자를 사용해야 하므로 직관적이지 못하다. 여러모로 포인터는 표현식이 복잡하기 때문에 레퍼런스를 대신 쓰는 경우가 많다.


32-2-나.예외 클래스 계층

예외 클래스도 클래스이므로 상속할 수 있고 다형성도 성립한다. 비슷한 종류의 예외라면 예외 클래스의 계층을 구성하여 반복되는 코드를 줄일 수 있고 가상 함수에 의해 예외 처리에도 다형성을 적용할 수 있다. 다음 예제는 숫자를 입력받되 100 이하의 양의 짝수만 입력받으며 나머지 숫자는 모두 예외로 처리한다.

  : InheritException
#include <Turboc.h>

class ExNegative
{
protected:
     int Number;

public:
     ExNegative(int n) : Number(n) { }
     virtual void PrintError() {
          printf("%d는 음수이므로 잘못된 값입니다.\n",Number);
     }
};

class ExTooBig : public ExNegative
{
public:
     ExTooBig(int n) : ExNegative(n) { }
     virtual void PrintError() {
          printf("%d는 너무 큽니다. 100보다 작아야 합니다.\n",Number);
     }
};

class ExOdd : public ExTooBig
{
public:
     ExOdd(int n) : ExTooBig(n) { }
     virtual void PrintError() {
          printf("%d는 홀수입니다. 짝수여야 합니다.\n",Number);
     }
};

void main()
{
     int n;

     for (;;) {
          try {
              printf("숫자를 입력하세요(끝낼 때 0) : ");
              scanf("%d",&n);
              if (n == 0) break;
              if (n < 0) throw ExNegative(n);
              if (n > 100) throw ExTooBig(n);
              if (n % 2 != 0) throw ExOdd(n);

              printf("%d 숫자는 규칙에 맞는 숫자입니다.\n",n);
          }
          catch (ExNegative &e) {
              e.PrintError();
          }
     }
}

음수에 대한 예외를 처리하는 ExNegative를 가장 최상위 클래스로 두고 음수에 대한 에러 메시지를 출력하는 PrintError를 가상 함수로 정의했다. 그리고 이 클래스를 상속하여 ExTooBig이라는 클래스를 정의하여 100을 초과하는 큰 수에 대한 예외를 처리하도록 했으며 ExTooBig으로부터 홀수 예외를 처리하는 ExOdd라는 클래스를 정의했다. 루트 예외 클래스인 ExNegative가 PrintError를 가상 함수로 정의했으므로 파생 클래스의 PrintError도 모두 동적으로 결합되는 가상 함수이다.
main에서 비슷한 예외들을 처리할 때는 에러 내용에 맞는 예외 객체를 생성하여 던지기만 하면 된다. catch는 각 예외 객체를 따로 처리할 필요없이 루트 예외 객체인 ExNegative에 대해서만 처리하면 되는데 왜냐하면 이 클래스로부터 파생된 클래스들은 모두 ExNegative와 IS A 관계에 있기 때문이다. catch에는 전달받은 예외 객체 e로부터 PrintError 함수만 호출하면 e의 타입에 맞는 가상 함수를 호출할 수 있어 예외의 종류를 판별하는 일은 신경쓰지 않아도 된다. e.PrintError가 다형적으로 에러를 처리한다.


32-2-다.예외와 클래스

클래스의 멤버 함수가 특정한 종류의 예외를 발생시킬 수 있다면 이 예외에 대한 모든 처리를 클래스안에 완벽하게 통합해 넣을 수 있다. 클래스 내부에 예외 클래스를 지역적으로 선언하면 이 클래스는 스스로 예외를 처리할 수 있으며 예외 처리 코드까지 포함하고 있으므로 어떤 상황에서도 예외를 처리할 수 있게 된다. 클래스를 설계할 때부터 예외 처리를 포함하는 것이 좋다. 다음 예제의 MyClass는 완전한 예외 처리 능력을 가지고 있다.

  : ExceptionClass
#include <Turboc.h>

class MyClass
{
public:
     class Exception
     {
     private:
          int ErrorCode;

     public:
          Exception(int ae) : ErrorCode(ae) { }
          int GetErrorCode() { return ErrorCode; }
          void ReportError() {
              switch (ErrorCode) {
              case 1:
                   puts("메모리가 부족합니다.");
                   break;
              case 2:
                   puts("연산 범위를 초과했습니다.");
                   break;
              case 3:
                   puts("하드 디스크가 가득 찼습니다.");
                   break;
              }
          }
     };
     void Calc() {
          try {
              if (TRUE/*에러 발생*/) throw Exception(1);
          }
          catch(Exception &e) {
              printf("에러 코드 = %d => ",e.GetErrorCode());
              e.ReportError();
          }
     }
     void Calc2() throw(Exception) {
          if (TRUE/*에러 발생*/) throw Exception(2);
     }
};

void main()
{
     MyClass M;
     M.Calc();
     try {
          M.Calc2();
     }
     catch(MyClass::Exception &e) {
          printf("에러 코드 = %d => ",e.GetErrorCode());
          e.ReportError();
     }
}

MyClass는 예외를 처리하는 Exception 클래스를 내부에서 선언하고 있으며 이 클래스의 멤버 함수 Calc의 내부는 Exception 지역 클래스를 사용하여 예외를 처리하고 있다. 계산중에 에러가 발생하면 적절한 Exception 예외 객체를 생성하여 던지며 Calc 함수내에서 이 객체를 받아서 처리한다. 예외 처리에 대한 모든 코드가 클래스에 캡슐화되어 있으므로 외부에서는 예외 처리에 대해 더 이상 신경쓰지 않아도 된다. main에서는 M.Calc()를 부르기만 하면 된다.
클래스에 포함된 예외 객체를 외부에서도 참조하려면 반드시 public 액세스 속성을 가져야 한다. 클래스 내부의 멤버 함수만 이 객체를 사용한다면 private이어도 상관없겠지만 모든 멤버 함수가 예외를 직접 처리할 수 없다면 호출부에서도 예외 객체를 잡을 수 있어야 하기 때문이다. 예제의 Calc2 함수는 예외를 던지기만 하고 직접 처리하지 않는다. 이럴 경우 호출부인 main에서 Calc2를 호출하는 문장을 try 블록에 작성해야 하며 catch문에서 MyClass::Exception을 잡을 수 있어야 한다. 그러기 위해서 Exception은 외부에서 참조할 수 있는 public이어야 하는 것이다.
예외를 처리하는데 클래스 계층을 구성하고 가상 함수를 이용한 다형성까지 활용하고 있으며 통합성을 높이기 위해 잘 사용하지 않는 지역 클래스까지도 선언한다. 여기에 추상 클래스와 순수 가상 함수까지 동원하면 훨씬 더 복잡해질 수도 있다. 잘 발생하지도 않는 예외 처리를 위해 이런 거창한 문법까지 동원하는 것은 왠지 격이 어울리지 않는 것 같아 보이기도 한다.
물론 응용 프로그램 수준에서 이런 예외 계층까지 구성하는 경우는 그리 흔하지 않다. 그러나 불특정 다수가 사용하는 라이브러리의 경우 숙련된 사용자를 가정할 수 없으므로 라이브러리가 견고해지려면 스스로 정교한 예외 처리를 할 수밖에 없다. 이런 라이브러리를 만들 때는 예외 처리에도 많은 신경을 쓸 수밖에 없고 신경쓴만큼 품질은 확실히 좋아진다. 그래서 C++은 튼튼하고 안정적인 객체를 만들기 위한 문법을 제공하는 것이다.
이때 라이브러리가 예외를 반드시 직접 처리할 필요는 없으며 때로는 직접 처리하기에 부적당한 경우도 많다. 예외 발생시 어떻게 대처할 것인가는 응용 프로그램에 따라 달라지는데 가벼운 예외라면 무시하고 지나갈 수도 있고 사용자에게 알릴 수도 있고 실행을 계속할 수 없을 정도로 치명적이리면 적극적으로 해결해야 하는 경우도 있다. 라이브러리는 예외 발생 사실과 원인 등 상세한 정보를 호출측에 전달하기만 하면 된다.


32-2-라.생성자와 연산자의 예외

C++의 예외 처리 기능은 생성자와 연산자에도 쓸 수 있다. 생성자의 경우는 리턴값이 없기 때문에 통상적인 방법으로는 예외를 처리하기가 무척 어렵고 연산자는 리턴값은 있지만 모든 리턴값이 의미가 있기 때문에 에러로 쓸만한 특이값을 선정할 수 없다. + 연산자의 리턴값이 -1이면 에러를 의미하는 것으로 약속하는 것은 불가능한데 연산 결과가 진짜로 -1인 것과 구분되지 않기 때문이다. 예외 처리 구문은 리턴값에 의존하지 않고 특정 조건이 되었을 때 원하는 곳으로 제어를 옮길 수 있으므로 생성자와 연산자의 에러 처리에도 사용할 수 있다.

  : CtorException
#include <Turboc.h>

class Int100
{
private:
     int num;

public:
     Int100(int a) {
          if (a <= 100) {
              num=a;
          } else {
              throw a;
          }
     }
     Int100 &operator+=(int b) {
          if (num + b <= 100) {
              num+=b;
          } else {
              throw num+b;
          }
          return *this;
     }
     void OutValue() {
          printf("%d\n",num);
     }
};

void main()
{
     try {
          Int100 i(85);
          i+=12;
          i.OutValue();
     }
     catch(int n) {
          printf("%d는 100보다 큰 정수이므로 다룰 수 없습니다.\n",n);
     }
}

Int100 클래스는 100이하의 정수만 다룰 수 있는 클래스로 설계되었다. 생성자와 += 연산자, 그리고 OutValue 함수를 정의하고 있는데 모두 적절한 예외 처리를 하고 있다. 생성자로 전달된 인수가 100보다 더 클 경우 초기값을 그대로 예외로 던짐으로써 객체 생성을 중지한다. 이 객체를 생성하는 함수는 안전한 객체 생성을 위해 try블록 안에서 객체를 선언하고 catch(int)에서 에러 메시지를 출력하면 된다.
예외 처리 구문없이 생성자의 에러를 처리하려면 디폴트 생성자를 따로 두고 생성을 대신하는 Init 따위의 함수에서 에러 처리를 할 수도 있을 것이다. 이 경우 사용자는 객체 생성 후 반드시 Init를 호출해야 하는 부담이 있다. 또는 호출원에서 객체를 생성하기 전에 전달할 인수값을 점검하는 방법을 생각해 볼 수도 있을 것 같지만 이런 방법은 객체를 동적으로 생성할 때만 사용할 수 있어 일반성이 없다.
+= 연산자는 값을 증가시키는데 초기화할 때는 100이하였더라도 증가 연산에 의해 100보다 큰 값이 될 수 있으므로 역시 예외를 던진다. 만약 예외 처리 구문을 쓰지 않는다면 틀린 값을 무시해 버리거나 아니면 틀린값을 그대로 가질 수밖에 없을 것이다. += 연산자는 연쇄적인 연산을 위해 클래스형의 레퍼런스를 리턴하므로 에러를 의미하는 특이값을 리턴하는 것도 불가능하다.
이 예에서 연산자의 예외를 처리하는 것은 별 문제가 없지만 생성자의 예외를 처리하는 코드는 약간의 제약이 있다. 객체를 try 블록 안에서 선언하면 이 객체는 블록 지역변수가 되어 버리므로 블록 바깥에서는 존재하지 않는다. main의 제일 끝에서는 i객체가 존재하지 않기 때문에 여기서 i를 참조할 수는 없다. 그래서 try 블록안에 객체 선언문이 있을 경우 try 블록은 이 객체를 완전히 사용하는 코드를 전부 포괄해야 한다. 다음 코드는 당연히 에러이다.

void main()
{
     try {
          Int100 i(85);
     }
     catch(int n) {
          printf("%d는 100보다 큰 정수이므로 다룰 수 없습니다.\n",n);
     }
     i+=12;
     i.OutValue();
}

객체 선언문만 try 블록으로 감싸 예외를 처리할 수는 없다는 얘기다. 이런 것들이 번거롭기 때문에 생성자는 예외 처리 구문을 쓰는 대신 성공적인 생성 여부를 표시하는 별도의 멤버를 두고 객체 생성 후에 이 멤버의 값을 평가하는 방법을 더 많이 사용한다. 생성자는 객체 생성에 실패할 경우 성공 여부 플래그에 에러 코드를 대입해 놓고 객체를 쓰는 쪽에서 이 플래그를 점검한다.



32-2-마.try 블록 함수

어떤 함수의 본체 어느 곳에서나 예외가 발생할 수 있다면 이 함수의 본체를 try 블록으로 완전히 묶어야 한다. 다음 예제의 divide 함수는 길이가 무척 짧기는 하지만 연산중에 예외가 발생할 소지가 있으므로 본체 전체가 try 블록에 싸여져 있다.

  : tryfunc
#include <Turboc.h>

void divide(int a, int d)
{
     try {
          if (d == 0) throw "0으로는 나눌 수 없습니다.";
          printf("나누기 결과 = %d입니다.\n",a/d);
     }
     catch(const char *message) {
          puts(message);
     }
}

void main()
{
     divide(10,0);
}

이런 경우 함수의 실질적인 코드가 try 블록 안에 모두 작성되어 있으므로 try 블록 자체를 함수의 본체로 만드는 것이 가능하다. 함수의 시작과 끝을 표시하는 { } 괄호를 없애 버리고 try와 catch를 함수의 본체인 것처럼 만들어 버리면 된다. 예제를 다음과 같이 수정해도 잘 컴파일될 것이다. 단, 비주얼 C++ 6.0은 아직 이런 형식을 지원하지 않으며 7.0이상과 gcc는 잘 지원한다.

void divide(int a, int d)
try {
     if (d == 0) throw "0으로는 나눌 수 없습니다.";
     printf("나누기 결과 = %d입니다.\n",a/d);
}
catch(const char *message) {
     puts(message);
}

이렇게 되면 try 블록과 이어지는 catch까지가 함수의 본체가 되며 인수의 사용 범위는 catch 까지 유효하므로 catch에서도 함수의 인수를 참조할 수 있다. 지금까지 익숙하게 봐왔던 함수와는 모양이 달라 생소해 보이는데 정 어색하면 기존 방식대로 { } 괄호를 싸는 표기법을 계속 사용하면 될 것이다. 그러나 생성자의 경우는 이런 표기법이 반드시 필요하다. 다음은 지금까지 실습을 위해 자주 사용했던 Position 클래스의 생성자인데 x, y 멤버에 대한 초기식을 초기화 리스트에 작성했다.

Position(int ax, int ay, char ach) : x(ax),y(ay) {
     ch=ach;
}

이 생성자에 약간의 에러 처리 기능을 더해 x가 음수일 때의 예외 처리 기능을 try 블록으로 작성해 보자. 결과는 다음과 같은데 모양이 상당히 희한하다.

  : tryctor
#include <Turboc.h>

class Position
{
private:
     int x,y;
     char ch;

public:
     Position(int ax, int ay, char ach)
     try : x(ax),y(ay) {
          if (ax < 0) throw ax;
          ch=ach;
     }
     catch (int a) {
          printf("%d는 음수 좌표라 객체가 보이지 않습니다.\n",a);
     }
     void OutPosition() {
          gotoxy(x, y);
          putch(ch);
     }
};

void main()
{
     try {
          Position Here(-1,10,'X');
          Here.OutPosition();
     }
     catch (int) {
          puts("무효한 객체임");
     }
}

생성자 본체가 시작되자 마자 try가 먼저 나오고 try와 시작 괄호 사이에 초기화 리스트가 배치된다. 이 표기법이 꼭 필요한 이유는 초기화 리스트 실행중에 발생할 수 있는 예외까지도 처리할 필요가 있기 때문이다. 기존의 일부 코드만 감싸는 try 블록 표기법으로는 본체 코드 전체를 감쌀 수는 있어도 초기화 리스트까지 예외 처리 블록에 포함시킬 수는 없다.
이 예제에서는 사실 초기화 리스트에서 특별히 발생할 예외가 없는 셈이다. 그러나 기반 클래스로부터 상속받은 멤버를 초기화한다거나 포함된 객체를 초기화하는 중에 예외가 발생할 가능성은 아주 많다. try 블록 함수 형태로 생성자를 작성하면 초기화 리스트의 코드도 try 블록에 포함되므로 좀 더 광범위한 예외 처리를 할 수 있다.
생성자에서 객체 생성 조건이 맞지 않을 경우의 예외를 처리하더라도 이 예외는 자동으로 다시 던져지도록 되어 있다. 왜냐하면 객체 생성 단계의 예외는 객체 혼자만의 문제가 아니라 이 객체를 선언한 곳과도 관련이 있으므로 객체를 쓰는 주체에게도 예외 사실을 반드시 알려야 하기 때문이다. 그래서 main에서 Here 객체를 선언하는 문장을 다시 try로 감싸고 있다. 만약 이 처리를 생략하면 생성자에서 발생한 예외는 미처리 예외가 되어 프로그램이 다운된다.


32-2-바.표준 예외

표준 C++ 라이브러리는 모든 예외의 루트로 사용할 수 있는 exception이라는 클래스를 정의한다. 이 클래스는 별다른 기능을 가지지 않으며 문자열 포인터를 리턴하는 what이라는 가상 함수를 제공한다. exception의 what은 별다른 출력이 없지만 파생 클래스는 원하는 문자열을 출력하도록 재정의할 수 있다. 표준 C++ 라이브러리는 exception으로부터 표준 예외 클래스들을 파생해 놓았다. 표준 예외는 크게 논리 에러와 런타임 에러로 나누어지며 exception에서 직접 파생되는 것들도 있다.
이 표준 예외들은 C++의 연산자들이 던지는데 나머지는 관련 부분에서 알아보기로 하고 여기서는 bad_alloc 예외에 대해서만 알아보자. 표준 이전의 C++ 컴파일러는 new 연산자가 할당에 실패할 때 NULL을 리턴하도록 되어 있지만 최신 컴파일러들은 bad_alloc 예외를 던지도록 되어 있으므로 예외 처리 구문으로 할당 실패를 처리할 수 있다. 다음 예제는 bad_alloc 예외를 발생시켜 보고 처리하는 예를 보여주는데 다소 위험한 예제이므로 함부로 실행하지 말고 구경만 하도록 하자. 기어이 테스트해 보고 싶다면 작업하던 모든 문서와 프로젝트를 저장하고 다운되도 상관없는 상태에서 실행해 보아라.

  : bad_alloc
#include <Turboc.h>
#include <new>

void main()
{
     int *pi[1000]={NULL,};
     int i;

     try {
          for (i=0;;i++) {
              pi[i]=new int[10000000];
              if (pi[i]) {
                   printf("%d번째 할당 성공\n",i);
              } else {
                   printf("%d번째 할당 실패\n",i);
              }
              Sleep(1000);
          }
     }
     catch(std::bad_alloc &b) {
          puts("에러 발생");
          b.what();
     }
     for (i=0;;i++) {
          delete [] pi[i];
     }
}

main에서 무한 루프를 돌며 40M씩 끊임없이 메모리를 할당하고 있는데 시스템의 메모리가 무한하지 않으므로 언젠가는 이 할당이 실패할 것이다. 이때 bad_alloc 예외가 발생하는데 catch에서는 에러가 발생했다는 사실을 문자열로 출력하기만 했다. 실제 예에서는 할당 실패시의 처리가 catch 블록에 작성되어야 할 것이다.
이 예제가 할당에 실패하는 시점은 시스템에 따라 다른데 512M 메모리를 실장한 시스템에서 테스트해 본 결과는 다음과 같다. 초반에는 어느 정도 빠른 속도로 할당이 진행되다가 10번을 넘기면 페이징 파일을 스왑하는 시간이 필요하므로 할당 시간이 점점 느려지기 시작한다. 40번을 넘을 때 쯤에는 약 1.8G 정도의 메모리를 할당하며 47번째에서 실패한다. 실패하는 시점에서 메모리 총 용량은 2G를 조금 넘는데 이는 메모리가 부족해서 실패하는 것이 아니라 응용 프로그램에 주어진 주소 공간이 고갈되었기 때문이다. 주소 공간이 더 넓은(3G) 윈도우즈 2003에서는 결과가 좀 다를 것이다.
프로그램을 강제로 종료하면 할당한 메모리는 모두 회수된다. 그러나 할당 과정에서 시스템은 무리하게 페이징 파일을 늘리기 때문에 늘어난 페이징 파일을 다시 원래대로 돌리기 위해 수 분 정도의 시간이 걸린다. 테스트 중에 메모리가 완전히 고갈되면 시스템이 다운될 위험도 있다. new 연산자가 bad_alloc 예외를 던지는 규정은 비교적 최신 기능이기 때문에 구형 컴파일러들은 이 예외를 지원하지 않는데 비주얼 C++ 6.0이 그렇다. 비주얼 C++7.0은 이 예외를 지원한다.
C++ 스팩은 new 연산자의 실패도 새로운 C++의 예외 처리 메커니즘에 통합시키기 위해 bad_alloc 예외를 도입했는데 이 예외는 사실 별다른 실용성을 느끼기 어렵다. 요즘같은 고성능 PC 환경에서 new는 좀처럼 실패하지 않는데다 실패할 때까지 반복하는 것이 오히려 더 위험하다. 설사 메모리가 부족한 환경이라도 과거처럼 NULL을 리턴하고 if문으로 점검하는 방법으로 충분히 점검할 수 있으므로 굳이 메모리 할당 실패 점검을 위해 예외 처리 구문까지 동원할 필요는 없다.



32-3.예외 지정

32-3-가.미처리 예외

throw가 예외를 던졌는데 이 예외를 받아줄 catch가 없는 경우는 아무도 이 예외를 처리하지 않는다. 설사 try 블록에 throw가 포함되어 있고 catch 블록이 있더라도 던진 예외와 타입이 맞는 catch가 없다면 이 예외도 미처리 예외가 된다. 미처리 예외는 terminate라는 함수가 처리하는데 이 함수는 기본적으로 abort를 호출하여 프로그램을 강제로 종료한다. 나중에 말썽을 부릴 바에야 개발중에 종료되어 예외를 분명히 알리는 것이 더 좋다는 식인데 고객앞에서 죽을 바에야 지금 당장 죽어라는 뜻이다. 그래서 예외 처리를 잘못하면 프로그램은 정리 작업도 하지 못하고 강제 종료된다.
만약 미처리 예외를 특별한 방식으로 처리하고 싶다면 미처리 예외의 핸들러를 따로 등록할 수 있다. 이때는 exception 헤더 파일에 선언되어 있는 다음 함수를 사용하는데 인수로 void func(void) 타입(terminate_handler)의 함수 포인터를 전달한다. 이후 미처리 예외가 발생할 경우 지정한 핸들러 함수가 호출된다.

terminate_handler set_terminate(terminate_handler ph)

미처리 예외 핸들러는 아무도 처리하지 않는 예외가 발생했을 때의 극단적인 예외를 처리할 수 있다. 다음 예제는 myterm이라는 함수를 미처리 예외 핸들러로 등록하여 메시지를 화면에 출력한 후 종료한다.

  : terminate
#include <Turboc.h>
#include <exception>
using namespace std;

void myterm()
{
     puts("처리되지 않은 예외 발생");
     exit(-1);
}

void main()
{
     set_terminate(myterm);
     try {
          throw 1;
     }
     catch(char *m) {
     }
}

main에서 정수형의 예외를 던졌는데 뒤쪽의 catch에는 정수형을 받는 부분이 없으므로 이 예외는 미처리 예외이다. 따라서 미리 지정한 myterm 함수가 호출된다. 이때 예외를 발생시킨 함수의 스택을 정리할 것인가 아닌가는 컴파일러에 따라 다르다.
임의의 객체를 받으려면 catch (...)을 사용하는데 이때 ...은 앞부분의 catch에서 처리되지 않은 모든 예외를 의미한다. catch (...)은 예외가 발생했다는 것만 알 수 있으며 어떤 예외가 왜 발생했는지는 알지 못하는 한계가 있다. 그래서 이 구문은 잘 사용되지 않는다. 개발중에 예외 사실을 단순히 알고만 싶을 때, 예외 발생 사실만 중요하고 정보는 필요없을 때 catch (...)을 사용한다. terminate는 전역적인 미처리 핸들러인데 비해 catch (...)은 국지적인 미처리 예외 핸들러라고 할 수 있다.

catch (...) {
     puts("뭔지 모르겠는데 하옇든 잘못되었습니다.");
}

throw에 의해 예외가 던져질 때 컴파일러는 try 블록 바로 밑의 catch를 등장하는 순서대로 점검하여 예외의 타입과 일치하는 catch를 찾는다. 그런데 catch (...)은 임의의 예외 타입을 모두 받을 수 있으므로 이 구문이 제일 앞에 있다면 뒤쪽의 catch는 절대로 호출되지 않을 것이다. 그래서 catch (...)은 반드시 모든 catch의 끝에 와야 한다. 여러 개의 catch가 있을 경우 올바른 배치는 왼쪽이다.

try { }
catch (int)
catch (char *)
catch (exception)
catch (...)
try { }
catch (...)
catch (int)
catch (char *)
catch (exception)

오른쪽과 같이 catch (...)이 제일 앞에 있으면 모든 예외를 이 catch가 받아서 처리할 것이므로 아래쪽의 catch는 있으나 마나한 존재가 된다. 순서대로 점검하기 때문에 포괄적인 범위의 예외 객체 핸들러가 가급적이면 뒤에 있어야 한다. 부모 클래스 타입, 자식 클래스 타입을 받는 핸들러가 둘 있다면 자식을 처리하는 핸들러가 먼저 나오고 부모를 처리하는 핸들러가 뒤에 나와야 한다.
컴파일러가 던져진 예외 객체로부터 핸들러를 찾을 때 예외 객체의 타입 점검은 지나칠 정도로 엄격하다. 컴파일러의 암시적인 타입 변환은 동작하지 않으므로 반드시 정확한 타입의 catch만 선택된다. 다음 코드를 보면 예외 처리가 잘 될 것 같지만 실제로 실행해 보면 미처리 예외가 된다.

try {
     if (TRUE) throw 1234;
}
catch(unsigned a) {
     printf("%d에 대한 예외 발생\n",a);
}

왜냐하면 1234는 int형 상수인데 catch는 unsigned만 받으므로 예외 객체의 타입이 맞지 않은 것이다. throw 1234u라고 표기하여 타입을 맞추면 catch(unsigned)와 정확하게 대응될 것이다. 대입이나 함수 호출같은 경우라면 컴파일러가 적당히 타입을 변환하지만 예외 객체는 정확한 타입만 찾는다.
심지어 int와 short 같이 길이만 다른 타입이나 int와 long처럼 잠재적으로는 다를 수 있더라도 실제로는 같은 타입조차도 다른 예외 객체로 인식된다. 단, 예외적으로 void * 타입을 받는 핸들러는 임의의 포인터 타입 객체를 받을 수 있고 부모 포인터 타입을 받는 핸들러는 자식 객체를 받을 수 있다.

32-3-나.예외 지정

함수를 작성할 때 함수의 원형 뒤쪽에 이 함수 실행중에 발생할 수 있는 예외의 종류를 지정할 수 있다. 인수 목록 다음에 throw 키워드와 괄호안에 예외의 타입을 지정하면 된다. 예를 들어 문자열 타입의 예외를 던지는 함수라면 다음과 같이 쓴다.

void func(int a, int d) throw(char *)

이 선언에 의해 func 함수는 char *형의 예외를 던진다는 것을 알 수 있다. 가능한 예외의 종류가 두가지 이상일 경우 괄호안에 예외의 타입들을 콤마로 구분해서 나열한다. 다음 예는 문자열 예외와 정수형 예외를 던지는 함수 func의 원형이다.

void func(int a, int d) throw(char *, int)

예외를 던지지 않는 함수는 throw()만 적고 괄호안을 비워 둔다. 함수 원형 뒤에 아무것도 적지 않으면 임의의 예외를 던질 수 있다는 뜻이다. 그래서 다음 두 함수의 뜻은 완전히 다르다.

void func(int a, int d) throw()
void func(int a, int d)

전자는 예외를 던지지 않으며 후자는 임의의 예외를 던질 수도 있고 아닐 수도 있다. 함수 원형에 던질 수 있는 예외의 종류를 지정하는 것은 문서화의 의미가 있는데 일종의 주석이라고 보면 된다. 이 함수를 사용하는 사람에게 어떤 종류의 예외가 발생할 수 있는지를 알려 주며 개발자는 원형 뒤쪽의 타입에 대해 적절한 catch문을 작성할 수 있다.
예외 지정은 함수 실행중에 발생할 수 있는 모든 예외에 대한 정보를 제공해야 하므로 함수 자신이 던지는 예외뿐만 아니라 이 함수가 호출하는 함수에서 발생할 수 있는 예외까지도 지정해야 한다. 그러나 만약 지정된 예외가 아닌 예외를 던지는 함수라 하더라도 이 호출이 금지되지는 않는다.

void fA() throw(int, double)
{
}

void fB() throw(char)
{
     fA();
}

fA가 int, double 예외를 던질 수 있으므로 이 함수를 호출하는 fB는 char뿐만 아니라 int, double도 명시해야 하는 것이 원칙이다. 자신이 호출하는 함수의 예외를 직접 처리하지 않는다면 예외는 계속 호출 스택의 아래쪽으로 다시 던져지기 때문이다. 그러나 이미 오래전에 개발된 라이브러리들은 예외 지정이 제대로 되어 있지 않기 때문에 이 지정이 틀렸다고 해서 fB가 fA를 호출하는 것을 금지하는 것은 이치에 맞지 않다. 또한 자신이 호출하는 함수가 내부적으로 호출하는 함수 목록을 정확히 파악한다는 것도 현실적으로 무척 어렵다.
만약 지정하지 않은 예외가 발생한다면 이때는 unexpected라는 함수가 호출되어 미지정 예외를 처리한다. unexcepted는 디폴트로 terminate를 호출하여 프로그램을 강제로 종료하는데 다음 함수를 사용하면 미처리 예외 핸들러를 변경할 수 있다.

unexpected_handler set_unexpected(unexpected_handler ph)

unexpeted_handler 타입은 인수도 리턴값도 없는 함수 포인터 타입이다. void func(void) 타입의 함수를 작성해 놓고 미지정 예외 핸들러로 지정하면 된다. 다음 예제를 보자.

  : unexpect
#include <Turboc.h>
#include <exception>
using namespace std;

void myunex()
{
     puts("발생해서는 안되는 에러 발생");
     exit(-2);
}

void calc() throw(int)
{
     throw "string";
}

void main()
{
     set_unexpected(myunex);
     try {
          calc();
     }
     catch(int) {
          puts("정수형 예외 발생");
     }
     puts("프로그램 종료");
}

calc에서 문자열 예외를 던지는데 대응되는 catch는 정수를 받는 것밖에 정의되어 있지 않다. 이때는 미리 지정한 myunex 함수가 호출되어 미지정 예외를 처리한다. 이 예제에서는 미지정 예외가 발생했음을 문자열로 알리기만 하고 exit로 프로그램을 종료했다. 또는 exit(-2) 대신에 throw 1 등으로 지정된 예외로 바꿔 다시 던질 수도 있다. 이렇게 되면 지정된 타입의 예외 핸들러로 제어를 옮긴다.
비주얼 C++은 미지정 예외 핸들러를 지원하지 않으므로 위 예제는 제대로 컴파일되지 않는다. gcc는 이 예제를 제대로 컴파일한다.


32-3-다.예외의 비용

이상으로 C++의 예외 처리 기능에 대해 연구해 봤는데 언어 차원에서 예외 처리 구문을 지원한다는데서 큰 의미를 찾을 수 있다. 언어의 표준이므로 적어도 이식성을 걱정할 필요는 없다. 이에 비해 구조적 예외 처리 기법인 SEH는 윈도우즈 운영체제가 정의하고 비주얼 C++ 컴파일러가 지원하므로 이 조합이 아니면 동작하지 않는다. 반면 예외 처리 구문은 표준을 준주하는 컴파일러에서는 항상 잘 동작한다.
그러나 고도로 정교한 프로젝트가 아닌 한 실무에서 이 기능을 전면적으로 사용하는 데 대해서는 다소 회의적으로 평가하는 사람도 있다. 왜냐하면 C++의 예외 처리 기능을 사용하면 프로그램의 성능이 눈에 뛸 정도로 느려지기 때문이다. 프로그램의 안정성과 유지, 보수의 편의성은 증가하지만 프로그램이 비대해지고 느려지는 반대 급부를 쉽사리 무시할 수는 없다.
예외 처리 구문에 의한 성능 저하는 상당한 정도인데 특히 스택 되감기 기능은 호출한 모든 스택을 정리하는 대 공사를 한다는 점만 봐도 얼마나 성능에 취약할지 상상이 간다. stackunwinding 예제의 divide 안에 있는 throw가 어떤 코드를 생성할지 상상해 보라. 물론 예외가 발생하지 않는다면 이런 속도상의 성능 저하는 거의 없으며 실제로 예외 발생 확률은 무척 낮다. 어쩌다가 극단적인 상황에서만 실행되는 코드이므로 정상적인 프로그램의 실행 속도를 떨어뜨리지는 않는다.
그러나 try, catch라는 키워드를 쓰는 것만으로도 프로그램의 용량은 무시못할 정도로 비대해지는 또 다른 문제가 있다. 왜냐하면 발생 빈도가 아무리 희박하다 하더라도 예외가 발생했을 때의 코드를 모조리 작성해 넣어야 하기 때문이다. 그래서 성능이 아주 중요하다면 C++의 예외 처리 기능을 사용하지 말아야 하며 전통적인 if문을 사용하는 것이 더 바람직할지도 모른다.
C++의 예외 처리 기능은 모든 면에서 완벽하다고 할 수 없고 컴파일러의 지원도 아직 미완성 단계이다. 아무리 좋아 보여도 전통적인 if 문을 모조리 예외 처리 구문으로 바꿀 수는 없다. 상황에 따라 예외를 처리하는 방법은 특수하기 때문에 모든 if문이 무난하게 예외 처리 구문으로 잘 변환되는 것은 아니다. 함수 내부에서 예외가 발생했을 때 호출원을 거꾸로 거슬러 올라가면서 스택을 정리하고 모든 객체를 파괴하는 것은 멋진 기능이기는 하다. 아무리 깊은 함수에서 예외가 발생했더라도 이를 잡아 낼 수 있으니 말이다. 그러나 동적으로 할당된 메모리는 그렇지 못한데 다음 예제를 보자.

  : exdynamic
#include <Turboc.h>

class SomeClass { };

void calc() throw(int)
{
     SomeClass obj;
     char *p=(char *)malloc(1000);

     if (TRUE/*예외 발생*/) throw 1;
     free(p);
}

void main()
{
     try {
          calc();
     }
     catch(int) {
          puts("정수형 예외 발생");
     }
}

calc가 예외를 일으킬 때 지역 객체 obj의 파괴자가 호출되어 필요한 정리를 한다. obj가 아무리 많은 메모리를 쓰더라도 문제되지 않는다. 그러나 예외 발생전에 malloc이나 new로 할당한 메모리는 해제될 기회가 없어 메모리 누수가 발생할 것이다. 메모리 할당 원칙에 의해 일단 할당한 메모리는 명시적으로 해제하지 않는 한 임의로 회수할 수 없다. throw는 남은 뒷부분의 코드를 무시하고 무조건 예외 핸들러로 점프해 버리기 때문이다. 이 문제를 해결하려면 포인터처럼 동작하며 스스로 할당된 메모리를 해제하는 스마트 포인터(auto_ptr)를 사용할 수 있다. 그러나 아무래도 스마트 포인터와 단순 포인터는 성능이나 사용 편의성 면에서 비교가 되지 않는다.
C++의 예외 처리 구문은 클래스 템플릿에는 쓸 수 없는데 왜냐하면 템플릿으로 전달되는 인수의 타입에 따라 발생할 수 있는 예외가 너무 다양해 언제 어떤 예외가 발생할 것인지를 도저히 예측할 수 없기 때문이다. 또한 예외 처리 구문은 멀티 스레드 환경에서 여러 가지로 문제가 있는데 안그래도 복잡한 멀티 스레드의 동기화 문제를 더 복잡하게 만든다. C++의 예외 처리 기능 자체는 멀티 스레드를 고려하여 동기적으로 설계되어 있지만 실제 적용시에는 여러 가지 복잡한 규칙을 따라야 하고 주의 사항도 많아 생각처럼 매끈하게 예외를 처리하기가 무척 어렵다. 간단히 말해 예외 처리는 템플릿과 멀티 스레드와는 궁합이 맞지 않다.
예외 처리 기능은 기본적으로 예외가 발생했을 때 적당한 핸들러를 찾아 점프하는 기능이다. 제어를 옮길 뿐이지 그 자체로 예외를 복구하지는 못한다. catch에서 어떤 조치를 취한 다음에 try 블록 안으로 다시 리턴할 수는 없다. 다음 예제를 보자.

  : exretry
#include <Turboc.h>

void main()
{
     int i;

     try {
          printf("1~100사이의 정수를 입력하시오 : ");
          scanf("%d",&i);
          if (i < 1 || i > 100) throw i;
          printf("입력한 수 = %d\n",i);
     }
     catch(int i) {
          printf("%d는 1~100 사이의 정수가 아닙니다.\n",i);
     }
}

1~100사이의 정수만 입력받기 위해 규칙에 어긋난 변수가 입력되면 예외 처리하도록 했다. 잘 동작하지만 틀린 입력을 적발할 수 있을 뿐이지 다시 입력하도록 하지는 못한다. 그렇게 하려면 while이나 for 등의 루프로 감싸 정확한 값이 입력될 때까지 반복하는 수밖에 없다. 그렇다면 다음과 같이 하는 것과는 무엇이 다른가?

for (;;) {
     printf("1~100사이의 정수를 입력하시오 : ");
     scanf("%d",&i);
     if (i >= 1 && i <= 100) break;
     printf("%d는 1~100 사이의 정수가 아닙니다.\n",i);
}
printf("입력한 수 = %d\n",i);

전통적인 루프로 성공할 때까지 반복하도록 했다. 어차피 루프가 필요하다면 그냥 if문으로 점검하는 것이 훨씬 더 편리하다. 깊은 호출 단계의 함수라면 예외 처리 구문의 장점이 드러나지만 적어도 단일 함수내에서는 if문이 훨씬 더 읽기 쉽고 성능도 좋다. 이 외에도 예외 처리는 스택 되감기 중 파괴자에서 예외 발생시의 애매함, 예외 핸들러에서 예외가 발생한 지점을 알 수 없다는 한계들이 존재한다. 그리고 견고한 프로그램을 만드는 것도 중요하지만 발생가능한 모든 예외를 일일이 다 처리한다는 것도 사실 비현실적이다.
아무리 좋은 기능이라도 남발하면 좋지 않으므로 꼭 필요한 곳에 잘 조절해서 쓰도록 하자. 비주얼 C++의 경우 예외 처리 구문을 사용할 것인지 아닌지를 프로젝트 옵션으로 선택할 수 있도록 되어 있는데 이 옵션을 끄면 스택을 되감는 코드는 생성하지 않는다. 컴파일러가 이런 옵션을 제공한다는 것은 무조건적인 사용이 좋기만 한 것이 아니라는 반증이기도 하다.







댓글 없음:

댓글 쓰기

[Effective C++] 항목 30 : 인라인 함수는 미주알고주알 따져서 이해해 두자.

인라인 함수를 사용하면 컴파일러가 함수 본문에 대해 문맥별 최적화를 걸기가 용이해집니다. 인라인 함수의 아이디어는  함수 호출문을 그 함수의 본문으로 바꿔치기하자는 것  남발했다가는 코드의 크기가 커질 게 뻔하다. 인라인 함수로 부풀려진 ...