[디딤돌 C++] 57. 예외 처리

이번에는 C++에서 제공하는 예외 처리를 살펴보기로 해요.

먼저 예외가 무엇인지 알아볼게요. 에러, 버그, 예외는 모두 정상적으로 동작하지 않을 때 사용하는 말들입니다. 이들을 구분하자면 에러는 사용자가 잘못 사용하여 프로그램이 정상적으로 동작하지 않는 것입니다. 그리고 버그는 개발자가 프로그램 논리를 잘못 작성하여 발생하는 것이죠. 예외는 외부 시스템이나 조건들에 의해 더 이상 수행하는 작업을 수행하지 못하는 것을 말합니다.

예를 들어 데이터 베이스 서버를 이용하는 온라인 판매 서비스는 판매 기능을 수행하기 위해 데이터 베이스 서버에 접근할 수 있어야 합니다. 그런데 데이터 베이스 서버가 죽어있거나 방화벽으로 막혀있다면 정상적으로 진행할 수가 없습니다. 이러한 상황을 예외라고 부릅니다.

물론 C++에서 제공하는 예외 처리가 이러한 예외를 처리할 때만 사용하는 것은 아닙니다. 사용자가 잘못 사용하거나 논리적으로 도달하지 못하는 상태일 때도 예외 처리 문법을 사용할 수 있습니다.

먼저 C언어에서 예외 처리하는 방법을 살펴보고 넘어갈게요. C언어에서 가장 간단한 예외 처리는 if 구문으로 조건을 확인하여 처리하는 것입니다.

//if 조건문으로 예외 처리

#include <stdio.h>
#define MIN_SCORE 0
#define MAX_SCORE 100
#define NOT_SCORE -1
int main()
{
    int score=0;
    printf("성적 입력:");
    scanf_s("%d",&score);
    if((score<MIN_SCORE)||(score>MAX_SCORE))
    {
        score = NOT_SCORE;
        printf("잘못 입력하였습니다.\n");
    }
    else
    {
        printf("입력한 성적은 %d입니다.\n",score);
    }
    return 0;
}

여전히 C++에서 이와 같이 예외 처리를 하는 것은 유효하면 이미 여러분께서는 사용하고 있겠죠.

그리고 <assert.h> 파일을 포함하여 assert 함수를 이용하여 예측한 값과 다를 때 어디에서 문제가 발생한 것인지 빠르게 확인할 수 있습니다. 다음 코드는 GetSum 함수가 잘 작성한 것인지 테스트하기 위한 코드입니다. 만약 GetSum(1,100);을 호출했을 때 결과가 5050이 아니라면 잘못 작성한 것이겠지요. 이 때 예측할 수 있는 값이 아닌지 확인하기 위해 assert(GetSum(1,100));과 같은 구문을 작성할 수 있어요. 만약 예측한 값과 같으면 프로그램은 아무런 반응도 하지 않습니다. 하지만 예측한 값과 다르면 런 타임 오류 메시지에 프로그램 코드 몇 번째 라인에서 오류가 발생했는지 확인할 수 있습니다.

//assert 함수 이용하여 예외 처리
#include <stdio.h>
#include <assert.h>
int GetSum(int s,int e);
int main()
{
    printf("1\n");
    assert(GetSum(1,100)==5050); //GetSum(1,100) 호출 결과는 5050이어야 함
    printf("2\n");
    return 0;
}
int GetSum(int s,int e)
{
    int sum=1; //초기화 값을 잘못 설정하여 버그 발생
    for(   ;s<=e; ++s)
    {
        sum += s;
    }
    return sum;
}
예외 발생

그리고 시스템 프로그래밍에서는 setjmp와 longjmp를 이용하여 예외처리 하는 방법도 있습니다. 여기에서는 이에 관해서는 다루지 않을게요. 관심이 있으신 분은 유닉스(혹은 리눅스) 시스템 프로그래밍에 관한 레퍼런스를 참고하세요.

이와 같은 예외 처리 방식외에도 C++언어에서는 try, catch, throw를 이용하여 구조적으로 예외 처리하는 방법을 제공하고 있습니다.

먼저 프로그램의 논리가 원하는 조건이 아니어서 다음 코드를 수행할 수 없는 상태에 빠지면 throw 문을 사용합니다. C++에서는 어떠한 형식이라도 throw 뒤에 표현할 수 있습니다. 예를 들어 동적으로 배열 클래스를 만들었을 때 인덱스 연산자 중복한다고 가정할게요. 인덱스 연산에 사용하는 인자가 유효한 범위가 아니라면 예외를 던져 논리적 버그가 발생했다는 것을 빠르게 알 수 있게 할 수 있습니다.

int &DinamicArray::operator[](int index)
{
    if((index<0)||(index>=max_capacity))
    {
        throw "유효하지 않은 인덱스를 사용했습니다.";
    }
    return base[index];
}

만약 사용하는 곳에서 던져진 예외를 처리하지 않으면 프로그램은 비정상적으로 종료합니다.

그리고 사용하는 곳에서는 예외가 발생할 수 있는 구문을 catch블록으로 감싸고 이어서 catch 블록을 지정하면 예외를 던졌을 때 catch 블록에서 이를 잡아 처리할 수 있습니다.

다음은 간단하게 예외 처리하는 방법을 확인하기 위한 예제입니다.

//간단한 예외 처리 예제
#include <iostream>
using namespace std;
void FunctionA(int value);
void FunctionB(int value);
int main()
{
    try
    {
        cout<<"main - 1"<<endl;
        FunctionB(3);
        cout<<"main - 2"<<endl;
        FunctionB(-3);
        cout<<"main - 3"<<endl;
    }
    catch(const char *exmsg)
    {
        cout<<"예외 발생:"<<exmsg<<endl;
    }
    cout<<"end of main"<<endl;
    return 0;
}
void FunctionB(int value)
{
    cout<<"FunctionB - 1"<<endl;
    FunctionA(value);
    cout<<"FunctionB - 2"<<endl;
}
void FunctionA(int value)
{
    cout<<"FunctionA - 1"<<endl;
    if(value>0)
    {
        cout<<"값:"<<value<<endl;
    }
    else
    {
        throw "음의 값을 전달하였습니다."; //예외 던지기
    }
    cout<<"FunctionA - 2"<<endl;
}

▷실행 결과

main - 1

FunctionB - 1

FunctionA - 1

값:3

FunctionA - 2

FunctionB - 2

main - 2

FunctionB - 1

FunctionA - 1

예외 발생:음의 값을 전달하였습니다.

end of main

예제 코드를 보면 main 함수에서 FunctionB를 두번 호출하고 있습니다. 그리고 FunctionB에서는 FunctionA를 호출하고 있죠. FunctionA에서는 입력 인자로 전달받은 값이 양수면 출력하고 그렇지 않으면 예외를 던지고 있습니다.

실행 결과를 보시면 예외를 던지면 main 함수의 catch 블록으로 이동하여 동작하는 것을 확인할 수 있어요. 정상적인 상황에서는 함수를 호출하면 피호출 함수를 수행하고 수행 후에 호출한 함수가 동작합니다. 하지만 예외를 던지면 가장 최근에 try 구문을 작성한 함수의 catch 블록으로 분기하는 것을 알 수 있습니다. 따라서 예외를 던지면 최근에 try문을 작성한 함수까지 스택이 되돌아갑니다. 이런 현상을 스택 되감기라고 말해요.

스택 되감기

참고로 throw 뒤에 어떤 형식이라도 표현할 수 있는 것은 장점이라고 생각하지 않습니다. C++의 자유 정신에 맞긴 하지만 Java나 C#처럼 Exception 클래스 형식이나 이를 기반으로 파생한 형식 개체만 전달하는 것보다 많은 정보를 얻을 수 없는 단점이 있습니다. Java나 C#의 예외 클래스에는 기본적으로 예외가 발생한 이유와 예외가 발생한 지점까지 온 경로를 제공하여 예외 원인을 파악하고 이를 해결할 수 있습니다.

//CS에서의 예외 throw
using System;

namespace CSharp_에서의_예외
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Main 1");
            Foo(-10);
            Console.WriteLine("Main 2");
        }
        private static void Foo(int value)
        {
            Console.WriteLine("Foo 1");
            Soo(value);
            Console.WriteLine("Foo 2");
        }
        private static void Soo(int value)
        {
            Console.WriteLine("Soo 1");
            if (value < 0)
            {
                throw new Exception();
            }
            Console.WriteLine("Soo 2");
        }
    }
}
예외 처리 실행 화면

그리고 try 블록 다음에는 여러 개의 catch 블록을 지정할 수 있습니다. 예외를 던지면 앞에 catch 블록부터 예외를 잡을 수 있으면 이를 처리하고 그렇지 않으면 다음 블록으로 이동합니다. 만약 어떠한 catch 블록에서도 예외를 잡을 수 없으면 프로그램은 종료합니다.

다음은 문자열과 int 형식의 예외를 잡는 catch 블록을 지정한 예제입니다. 그리고 예외를 던지는 곳에서는 입력 인자에 따라 정수, 문자열, 실수를 던지게 하였습니다. 실행해 보면 정수 예외와 문자열 예외는 catch 블록에서 처리를 하지만 실수 예외는 처리하지 못하여 프로그램이 종료하는 것을 알 수 있습니다.

//여러 개의 catch 블록 사용
#include <iostream>
using namespace std;
void FunctionA(int value);
void FunctionB(int value);
int main()
{
    int i = 0;
    for(i=0;i>-5;i--)
    {
        try
        {
            cout<<"main: before call  "<<i<<endl;
            FunctionB(i);
            cout<<"main: after call  "<<i<<endl;
        }
        catch(const char *exmsg)
        {
            cout<<"예외 발생:"<<exmsg<<endl;
        }
        catch(int exvalue)
        {
            cout<<"예외 발생:"<<exvalue<<endl;
        }
    }
    cout<<"end of main"<<endl;
    return 0;
}

void FunctionA(int value)
{
    cout<<"FunctionA - 1"<<endl;
    FunctionB(value);
    cout<<"FunctionA - 2"<<endl;
}

void FunctionB(int value)
{
    cout<<"FunctionB - 1"<<endl;
    switch(value)
    {
    case -1: throw 1;
    case -2: throw "예외 문자열";
    case -3: throw 3.14;
    default: cout<<"값:"<<value<<endl; break;
    }
    cout<<"FunctionB - 2"<<endl;
}

▷ 실행 결과

main - 1

FunctionB - 1

값:3

FunctionB - 2

main - 2

FunctionB - 1

예외 발생:음의 값을 전달하였습니다.

end of main

main: before call  0

FunctionB - 1

값:0

FunctionB - 2

main: after call  0

main: before call  -1

FunctionB - 1

예외 발생:1

main: before call  -2

FunctionB - 1

예외 발생:예외 문자열

main: before call  -3

FunctionB - 1

만약 특정 형식이 아닌 모든 형식의 예외를 잡기를 원하면 catch(…) 으로 표시한 블록을 지정하면 무엇이든지 처리합니다.

그리고 예외 처리에서 주의할 점으로 예외 클래스를 계층화한 후 예외 종류에 따라 던지는 형식을 다르게 하길 원할 때 catch 블록은 파생 형식을 받는 것을 먼저 배치하세요. C++은 다형성 특징에 의해 기반 형식 변수로 파생 형식 개체를 참조할 수 있는 특징 때문에 기반 형식을 처리하는 catch 블록을 앞에 배치하면 모든 예외를 처리해 버립니다.

다음은 예외 클래스 A, B, C를 일반화 관계로 정의한 후에 예외를 발생하고 catch 블록을 기반 형식에서 파생 형식 순으로 배치한 예제 코드입니다.

//예외 클래스의 계층화 - 기반 예외 catch문을 맨 앞에
#include <iostream>
using namespace std;
class A{   };
class B:public A{   };
class C:public B{   };
void FunctionA(int value);
int main()
{
    for(int i=0;i<3;i++)
    {
        try  
        {
            FunctionA(i);
        }
        catch(A *a){    cout<<"예외 A"<<endl;    }
        catch(B *b){    cout<<"예외 B"<<endl;    }
        catch(C *c){    cout<<"예외 C"<<endl;    }
    }
    return 0;
}
void FunctionA(int value)
{ 
    switch(value)
    {
    case 0: throw new A();
    case 1: throw new B();
    case 2: throw new C();
    }    
}

▷ 실행 결과

A
A
A

실행해 보면 예외 종류에 관계없이 기반 형식 예외를 잡는 catch 블록만 수행함을 알 수 있습니다.

다음은 catch 블록을 역순으로 배치한 코드입니다.

//예외 클래스의 계층화 - 파생 예외 catch문을 맨 앞에
#include <iostream>
using namespace std;

class A
{
};

class B:public A
{
};

class C:public B
{
};

void FunctionA(int value);

int main()
{
    for(int i=0;i<3;i++)
    {
        try  
        {
            FunctionA(i);
        }
        catch(C *c)
        {
            cout<<"예외 C"<<endl;
        }        
        catch(B *b)
        {
            cout<<"예외 B"<<endl;
        }
        catch(A *a)
        {
            cout<<"예외 A"<<endl;
        }
    }
    return 0;
}

void FunctionA(int value)
{ 
    switch(value)
    {
    case 0: throw new A();
    case 1: throw new B();
    case 2: throw new C();
    }    
}

▷ 실행 결과

A
B
C

실행해 보면 예외 종류에 맞게 처리하는 것을 알 수 있습니다.

C++언어로 프로그래밍할 때 STL(Standard Template Libary, 표준 템플릿 라이브러리)같은 라이브러리를 사용하는 것은 매우 흔한 일입니다. 이와 같은 라이브러리에서는 사용자가 잘못 사용하여 더 이상 처리하지 못할 때 예외를 던지게 구현해 놓았습니다. 여러분께서는 적절한 예외 처리를 사용하면 보다 효과적으로 프로그래밍할 수 있습니다. 그리고 사용하는 라이브러리에서 어떠한 예외 형식을 전달하는지 이해한다면 더 나은 프로그래밍을 할 수 있겠죠.

여기에서는 예외처리에 관한 사항은 여기까지 설명하기로 할게요.