Notice
Recent Posts
Recent Comments
Link
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Archives
Today
Total
05-17 00:00
관리 메뉴

nomad-programmer

[Programming/C++] 예외에 안전한 코드 생성 본문

Programming/C++

[Programming/C++] 예외에 안전한 코드 생성

scii 2023. 1. 23. 16:24

구조적 예외 처리는 예외 상황을 효율적으로 처리할 수 있게 도와주지만 잘못 사용하는 경우에는 화를 입을 수도 있다. 구조적 예외 처리에는 어떤 문제점이 존재하고 또 어떻게 해결해야 하는지 알아보자.

리소스를 정리하기 전 예외가 발생한 경우의 문제점

구조적 예외 처리에서 가장 빈번하게 일어나는 문제는 리소스를 정리하기 전에 함수가 종료되는 경우다. 다음의 예제는 예외로 인해서 할당된 메모리가 해제되지 못하는 문제점을 가지고 있다.

#pragma warning(disable: 4996)

#include <iostream>

using namespace std;

void A();
void B();

int main(void) {
    try {
        A();
    }
    catch (const char* ex) {
        cout << "catch: " << ex << endl;
    }
    
    return 0;
}

void A() {
    // 메모리 할당
    char* p = new char[100];

    cout << "[begin exception]" << endl;
    // 예외 발생 함수 호출. 여기서 예외가 던져지면 곧바로 A() 함수가 종료되고, 실행 흐름을
    // main() 함수로 이동한다. 그렇기 때문에 동적할당한 메모리를 해제되지 않는다.
    B();
    cout << "[end exception]" << endl;

    // 메모리 해제
    delete[] p;
    p = nullptr;
}

void B() {
    throw "Exception!";
}


// 결과
[begin exception]
catch: Exception!

결과를 보면 어떤 방식으로 실행하는지 알 수 있다. 처음 A() 함수가 실행되면 메모리를 할당하고 곧바로 'begine exception' 이라는 문제열을 출력한다. 

이어서 B() 함수를 호출하는데 이 함수에서는 예외를 던진다. 그래서 A() 함수는 더 이상 실행되지 않고 바로 종료된다. 

결국 A() 함수의 앞에서 할당된 메모리가 해제되지 않는 문제가 발생했다. 메모리가 샌(Memory Leak) 것이다. 꼭 메모리 할당 뿐만 아니라 함수의 끝에서 무언가 정리 작업을 해주는 경우라면 모두 이 문제점에 노출이 되어 있는 셈이다. 이 문제점을 효율적으로 해결할 수 있는 방법을 알아보자.


메모리 누수 (Memory Leak)

릭(Leak)은 물 같은 것이 새는 상황을 말하는 단어다. 예를 들어 water leak이라고 하면 우리말로 '누수'와 같은 뜻으로 물통에 담아놓은 물이 한 방울씩 새어 나가는 장면을 생각하면 된다.

결국 메모리 누수란 메모리가 조금씩 새어 나가는 현상을 말하는데, 위의 예제에서처럼 할당한 메모리를 해제해주지 않을 때 발생한다. 할당만하고 해제해주지 않는 상황을 반복하면 사용 가능한 메모리가 조금씩 줄어들다가 결국은 컴퓨터의 메모리가 고갈될 수 있다. 그러므로 메모리 누수는 반드시 예방해야 한다.

비슷한 말로 리소스 누수 (Rsource Leak)라는 용어가 있다. 리소스는 일반적으로 메모리, 하드디스크와 같은 포괄적인 의미의 자원을 뜻한다. 즉, 메모리 뿐만 아니라 다른 종류의 자원이 새어 나가는 현상까지 통틀어서 일컫는 용어가 바로 리소스 누수이다.


소멸자로 리소스 문제 해결

객체의 경우 소멸자가 자동으로 호출된다. 예외 때문에 종료되는 경우에도 객체 소멸자는 반드시 호출된다. 바로 이 점을 이용해 리소스를 해제하는 용도의 클래스를 만들 수 있다. 일반적으로 이런 용도의 클래스를 스마트 포인터(Smart Pointer) 라고 부른다. 다음 예제는 스마트 포인터 클래스를 간단하게 구현하고 있다.

#pragma warning(disable: 4996)

#include <iostream>

using namespace std;

// 스마트 포인터 클래스
class SmartPointer {
public:
    SmartPointer(char* p) :ptr(p) {}
    ~SmartPointer() {
        cout << "delete memory!" << endl;
        delete[] ptr;
    }

public:
    char* const ptr;
};

void A();
void B();

int main(void) {
    try {
        A();
    }
    catch (const char* ex) {
        cout << "catch: " << ex << endl;
    }
    
    return 0;
}

void A() {
    // 메모리 할당
    char* p = new char[100];

    // 메모리를 스마트 포인터에 포관한다.
    SmartPointer sp(p);

    cout << "[begin exception]" << endl;
    // 예외 발생 함수 호출. 여기서 예외가 던져지면 곧바로 A() 함수가 종료되고, 실행 흐름을
    // main() 함수로 이동한다. 그렇기 때문에 동적할당한 메모리를 해제되지 않는다.
    B();
    cout << "[end exception]" << endl;
}

void B() {
    throw "Exception!";
}


// 결과
[begin exception]
delete memory!
catch: Exception!

SmartPointer라는 이름의 스마트 포인터 클래스를 정의하고 있다. 이 클래스가 하는 일은 아주 간단한데 생성자에서 동적으로 할당된 메모리의 주소를 인자로 받아 멤버 변수에 저장하는 것이 전부다. 또 소멸자에서는 보관한 주소를 사용해서 메모리를 해제재주는 것이 전부다.

SmartPointer 클래스를 사용하는 방법 역시 매우 단순하다. SmartPointer 객체를 생성하면서 동적으로 할당된 메모리의 주소를 인자로 넘겨준다. 함수가 정상 종료이건 예외에 의해서 종료되건 이 객체 소멸자는 반드시 호출될 것이고 객체에 보관된 메모리도 반드시 해제된다.

C++에서는 이런 용도의 스마트 포인터를 이미 제공하고 있다. 그 클래스의 이름은 unique_ptr이다.


생성자에서 예외가 발생한 경우의 문제점

일반적으로 객체는 예외에 안전하다고 볼 수 있다. 예외가 발생한 경우에도 객체 소멸자가 반드시 호출될 것이고, 자신의 리소스도 모두 해제할 것이기 때문이다. 하지만 생성자에서 예외가 발생한 경우에는 이 가정이 깨지고 만다. 

DynamicArray 파일의 전체 코드는 다음의 링크에 가면 볼 수 있다.

2023.01.21 - [Programming/C++] - [Programming/C++] 예외 처리

 

[Programming/C++] 예외 처리

예외 처리가 프로와 아마추어를 구분하는 기준이 될 수 있다는 점을 기억하자. 반환 값을 사용한 예외 처리 다음의 예제를 살펴보자. // DynamicArray.h #pragma once class DynamicArray { public: DynamicArray(int ar

nomad-programmer.tistory.com

// DynamicArray.h

DynamicArray::DynamicArray(const int size) {
    arr = new int[size];
    this->size = size;

    // 예외 발생
    throw MemoryException(this, 0);
}

DynamicArray::~DynamicArray() {
    std::cout << "DynamicArray Destruction!" << endl;
    delete[] arr;
    arr = nullptr;
    size = 0;
}
#pragma warning(disable: 4996)

#include "DynamicArray.h"
#include "MyException.h"

#include <iostream>

using namespace std;

int main(void) {
    try {
        DynamicArray arr(100);
    }
    catch (MyException& ex) {
        cout << "description: " << ex.GetDescription() << endl;
    }
    
    return 0;
}


// 결과
description: out of memory!

위의 예제에서는 DynamicArray 클래스의 생성자에서 메모리 할당 한 후 고의적으로 예외를 발생시킨다. 언뜻 생각하기에는 어차피 소멸자에서 메모리를 해제할 것이기 때문에 아무런 문제가 없어 보인다. 그런데 결과를 보면 DynamicArray 객체의 소멸자가 호출되지 않았다. 
즉, 생성자에서 할당한 메모리도 해제되지 않았다는 뜻이다. 이는 C++에 다음과 같은 규칙이 있기 때문이다.

생성자가 올바르게 종료된 경우에만 객체를 생성한 것으로 간주한다.

 

생성자에서 예외가 발생한 경우라면 정상적으로 종료되지 않은 것이고 객체도 생성되지 않은 것이다. 객체가 생성되지 않았으니 소멸자도 호출될 일이 없다.


예외 다시 던지기

생성자에서 예외를 던지는 것은 매우 바람직한 일이다. 일반적인 함수라면 반환 값이 있기 때문에 예외를 던지는 대신 반환 값을 사용해서 예외 상황을 알릴 수 있지만 생성자는 반환 값이 없기 때문에 예외를 던지는 것이 예외 상황을 알리는 유일한 수단이 된다.

즉, 예외를 던지는 것은 그대로 내버려 두고 메모리 누수를 막는 방법을 생각해볼 수 있다.

// DynamicArray.cpp

DynamicArray::DynamicArray(const int size) {
    try {
        arr = new int[size];
        this->size = size;

        // 예외 발생
        throw MemoryException(this, 0);
    }
    // 모든 종류의 예외를 잡아낸다.
    catch (...) {
        DynamicArray::~DynamicArray();
        // 받은 예외를 그대로 다시 던진다.
        throw;
    }
}


// 결과
DynamicArray Destruction!
description: out of memory!

받은 예외 다시 던저기의 사용 예이다. 이렇게하면 생성자에서 발생한 예외가 외부로 던져니느 것에는 아무런 변함이 없다. 다만 예외를 중간에 가로채서 필요한 정리 작업을 한 후에 예외를 다시 던지는 것이다.


소멸자에서의 예외는 반드시 막아야 한다.

구조적 예외 처리의 내부적인 실행 방식 때문에, 객체의 소멸자에서 예외가 던져지는 경우에는 프로그램이 비정상 종료될 수 있다. 그러므로 소멸자 밖으로는 예외가 던져지지 않도록 막아야 한다. 아주 중요한 것이다.

그렇게 하기 위해서 생성자에서 했던 것처럼 소멸자의 모든 코드를 try 블럭으로 감쌀 필요가 있다. 그리고 이렇게 잡아낸 예외는 절대로 소멸자 밖으로 다시 던져서는 안 된다.

DynamicArray::~DynamicArray() {
    // 소멸자에서 발생하는 모든 예외를 잡아야 하는 경우
    try {
        delete[] arr;
        arr = nullptr;
        size = 0;
    }
    catch (...) {
    }
}

C++에서 제공하는 예외 관련 기능

C++에서 제공해주는 기능들 중에서 예외와 관련한 것을 살펴보자. 가장 비번하게 사용하는 기능 두 가지를 소개한다.

unique_ptr

unique_ptr 클래스는 C++에서 제공하는 스마트 포인터다. unique_ptr을 사용하는 예를 보자. 참고로 C++11 이전에는 auto_ptr 클래스로 사용되었다. 그런데 문제가 있어 C++11 이후로 unique_ptr로 변경되었다. 따라서 C++11 이후 버전에서는 auto_ptr 클래스는 사용할 수 없다.

#pragma warning(disable: 4996)

// unique_ptr 클래스를 사용하기 위해 memory 헤더 파일을 포함시켜야 한다.
#include <memory>
#include <iostream>

using namespace std;

int main(void) {
    // 스마트 포인터 생성
    // int 타입을 가리킬 수 있는 스마트 포인터 p를 정의한다.
    // int 타입의 값을 하나 할당해서 생성자에 인자로 넘겨준다.
    unique_ptr<int> p(new int);

    // 스마트 포인터 객체가 마치 진짜 포인터인 것처럼 사용할 수 있다.
    *p = 100;

    // 메모리를 따로 해제해줄 필요가 없다.
    
    return 0;
}

unique_ptr 클래스는 모든 타입의 포인터를 보관할 수 있는데, 위의 예제는 int 타입의 포인터를 보관하기 위해 unique_ptr<int> 처럼 해주었다.

new int를 통해 int 타입의 값을 하나 할당하고 그 주소를 p의 생성자로 전달한다. 이제 이 메모리는 스마트 포인터에 의해서 관리되므로 더 이상 신경쓸 것이 없다.


메모리 할당 시에 발생하는 예외

동적으로 메모리를 할당할 때 컴퓨터에 충분한 메모리가 남아있지 않다면 어떻게 될까? 이런 경우에 new, new[] 연산자는 bad_alloc이라는 예외를 던진다. 다음 예제에서는 이 예외를 받아 처리하는 방법을 보여준다.

#pragma warning(disable: 4996)

// bad_alloc 클래스를 사용하기 위해서 new 헤더 파일을 포함시켜야 한다.
#include <new>
#include <iostream>

using namespace std;

int main(void) {
    try {
        // 많은 양의 메모리를 할당해서 bad_alloc 예외가 발생하게 만든다.
        char* p = new char[0xFFFFFFF0];
    }
    // bad_alloc& 타입으로 예외를 받는다.
    catch (bad_alloc& ex) {
        // bad_alloc 클래스에는 예외에 대한 설명 문자열을 반환하는 what() 이라는 멤버 함수가 있다.
        cout << ex.what() << endl;
    }
    
    return 0;
}

bad_alloc 예외를 잡기 위해서 new, new[] 연산자를 사용해서 메모리를 동적으로 할당하는 부분을 try 블럭으로 감싸주어야 한다.. 그리고 bad_alloc& 타입의 예외를 받을 수 있게 catch 블럭을 준비한다.

만약 시스템에 사용 가능한 메모리가 부족해서 메모리 할당을 수행할 수 없는 경우에는 bad_alloc 예외가 발생하고 catch  블럭에서 예외를 잡게 된다. bad_alloc 클래스의 멤버 함수 what()을 호출함으로써 예외의 대한 설명을 볼 수 있다.

bad_alloc 예외를 잡는 방법은 위에서 본 것처럼 아주 간단하다. 그런데 중요한 사실이 하나 있다. 이 규칙은 반드시 따라야 한다.

  • new, new[] 연산자를 사용할 때마다 반드시 예외 처리를 해야 한다.

지금까지 등장한 예제에서는 그렇게 하지 않았지만 실제 현장에서 프로그램을 만들 때는 new, new[] 연산자를 사용하는 모든 코드에서 bad_alloc 예외가 던져질 수 있다고 가정을 해야 한다.

일반적으로 컴퓨터에 메모리가 부족한 경우는 거의 발생하지 않기 때문에 초보자 뿐만 아니라 경력이 있는 개발자도 이 부분을 놓치기 쉽다. 하지만 100% 발생하지 않는다고 보장할 수는 없는 일이고 개발자인 이상 0.1%의 확률까지도 모두 처리해줘야 한다.
많은 개발자들이 예외 처리를 소홀히 하는 경향이 있다. 하지만 다음의 질문은 예외 처리가 얼마나 중요한 것인지 깨닫게 해준다.

만약, 부가 기능이 많이 있지만 가끔씩 죽는 프로그램과 부가 기능은 조금 부족하지만 절대로 죽지 않는 프로그램이 있다면 어떤 프로그램을 구입하여 사용하겠는가?

 

예외 처리는 그만큼 필수적인 항목이다. 이 사실을 깨닫고 있는 것만으로도 다른 사람들보다 한 발 앞선 셈이다.


CMemoryException 클래스

MFC를 사용한 윈도우즈 프로그래밍을 한다면 bad_alloc 대신 CMemory Exception 이라는 클래스를 사용해야 한다. C++은 아주 유연해서 bad_alloc 대신 다른 객체를 던지는 것도 허용하는데, MFC의 경우에는 CMemoryException을 던지게 고쳐 놓았다.

Comments