[Effective C++] 3. 자원 관리 [2/2]
이 글은 제 개인적인 공부를 위해 작성한 글입니다.
틀린 내용이 있을 수 있고, 피드백은 환영합니다.
항목 15 : 자원 관리 클래스에서 관리되는 자원은 외부에서 접근할 수 있도록 하자.
자원 관리 클래스는 실수로 터질 수 있는 자원 누출을 막아주는 역할을 하는 듬직한 클래스이다. 똑바로 설계된 시스템이라면 자원 누출이 없어야 한다는 것은 기본적으로 갖추어야 한다. 사실 세상이 쉽다면 자원을 가지고 어떻게 해야 할 때 이런 클래스만 사용해도 될 것이다. 굳이 실제 자원을 조작하느라 손을 더럽힐 이유가 없다. 하지만 세상은 그리 만만하지 않다. 이미 현장에서 열심히 쓰이고 있는 수많은 API들이 자원을 직접 참조하도록 만들어져 있어서, 실제 자원을 직접 조작해야 할 것이다.
항목 13에서 createInvestment 등의 팩토리 함수를 호출한 결과를 담기 위해 스마트 포인터를 사용하는 아이디어가 나왔었다.
1
std::tr1::shared_ptr<Investment> pInv(createInvestment());
이때 어떤 Investment 객체를 사용하는 함수로서 우리가 사용하려고 하는 것이 다음과 같다고 가정해 보자.
1
int daysHeld(const Investment* pi);
그리고 이렇게 호출할 것이다.
1
int days = daysHeld(pInv); // 에러
애석하게도 이 코드는 컴파일이 안 된다. daysHeld 함수는 Investment* 타입의 실제 포인터를 원하는데, 우리는 tr1::shared_ptr
사정이 이렇다 보니, RAII 클래스(지금은 tr1::shared_ptr)의 객체를 그 객체가 감싸고 있는 실제 자원(Investment*)으로 변환할 방법이 필요해진다. 이런 목적에 일반적인 방법을 쓴다면 두 가지가 있는데, 하나는 명시적 변환이고 또 다른 하나는 암시적 변환이다.
tr1::shared_ptr 및 auto_ptr은 명시적 변환을 수행하는 get이라는 멤버 함수를 제공한다. 다시 말해 이 함수를 사용하면 각 타입으로 만든 스마트 포인터 객체에 들어 있는 실제 포인터의 사본을 얻어낼 수 있다.
1
int days = daysHeld(pInv.get());
제대로 만들어진 스마트 포인터 클래스라면 거의 모두가 그렇듯, tr1::shared_ptr과 auto_ptr은 포인터 역참조 연산자(operator-> 및 operator*)도 오버로딩하고 있다. 따라서 자신이 관리하는 실제 포인터에 대한 암시적 변환도 쉽게 할 수 있다. 다음의 코드를 봐보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Investment
{
public:
bool isTaxFree() const;
...
};
Investment* createInvestment();
std:tr1::shared_ptr<Investment> pi1(createInvestment());
bool taxable1 = !(pi1->isTaxFree());
...
std::auto_ptr<Investment> pi2(createInvestment());
bool taxable2 = !((*pi2).isTaxFree());
...
RAII 객체 안에 들어 있는 실제 자원을 얻어낼 필요가 종종 생기기 때문에, RAII 클래스 설계자 중에는 암시적 변환 함수를 제공하여 자원 접근을 매끄럽게 할 수 있도록 만드는 분도 잇다. 예를 들어, 어떤 하부 수준 C API로 직접 조작이 가능한 폰트를 RAII 클래스로 둘러싸서 쓰는 경우를 생각해 보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
FontHandle getFont(); // C API에서 가져온 함수 - 매개변수는 생략
void releaseFont(FontHandle fh); // C API에서 가져온 함수
class Font
{
public:
// 값에 의한 전달이 수행되는 것에 주의. 자원 해제를 C API로 하기 때문
explicit Font(FontHandle fh) : f(fh) {}
~Font() { releaseFont(f); }
private:
FontHandle f;
};
하부 수준 C API는 FontHandle을 사용하도록 만들어져 있으며 규모도 무척 크다고 가정하면, Font 객체를 FontHandle로 변환해야 할 경우도 적지 않을 것이라는 예상을 해볼 수 있다. Font 클래스에서는 이를 위한 명시적 변환 함수로 get을 제공할 수 있을 것이다.
1
2
3
4
5
6
7
Class Font
{
public:
...
FontHandle get() const { return f; }
...
};
이렇게 해 두면 어쨌든 쓸 수 있긴 한데, 사용자는 하부 수준 API를 쓰고 싶을 때마다 get을 호출해야 할 것이다.
1
2
3
4
5
6
7
void changeFontSize(FontHandle f, int newSize);
Font f(getFont());
int newFontSize;
...
changeFontSize(f.get(), newFontSize);
변환할 때마다 무슨 함수를 호출해 주어야 한다는 점이 짜증나서 Font 클래스를 안쓰고 말겠다는 프로그래머도 분명히 나오게 된다. 이것 때문에 폰트 자원이 누출될 가능성이 늘어난다면 그것만큼 애석한 일도 없을 것이다. Font 클래스를 설계한 가장 큰 목적이 폰트 자원의 누출을 막는 것인데도 말이다.
대안이 없진 않다. FontHandle로의 암시적 변환 함수를 Font에서 제공하도록 하면 되는거다.
1
2
3
4
5
6
7
class Font
{
public:
...
operator FontHandle() const { return f; } // 암시적 변환 함수
...
};
암시적 변환 함수 덕택에 C API를 사용하기가 훨씬 쉬워지고 자연스러워진다.
1
2
3
4
Font f(getFont());
int newFontSize;
...
changeFontSize(f, newFontSize);
그렇다고 마냥 좋은 것만은 아니다. 암시적 변환이 들어가면 실수를 저지를 여지가 많아진다. 진짜 Font를 쓰려고 한 부분에서 원하지도 않았는데 FontHandle로 바뀔 수도 있다는 거다.
1
2
3
4
Font f1(getFont());
...
FontHandle f2 = f1;
// 원래 의도는 Font 객체를 복사하는 것이었는데, 엉뚱하게도 f1이 FontHandle로 바뀌고 나서 복사가 되어버렸다.
이렇게 되면 Font 객체인 f1이 관리하고 있는 폰트가 f2를 통해서도 직접 사용할 수 있는 상태가 된다. 하나의 자원이 양다리를 걸치고 있는 이와 같은 상황은 좋지 않다. f1이 소멸될 시점을 생각해보면, 당연히 폰트가 해제될 텐데, 그럼 f2는 해제된 폰트에 달려있는 꼴이 된다.
RAII 클래스를 실제 자원으로 바꾸는 방법으로서 명시적 변환을 제공할 것인지(get 멤버 함수등) 아니면 암시적 변환을 허용할 것인지에 대한 결정은 그 RAII 클래스만의 특정한 용도와 사용 환경에 따라 달라진다. 어쨌든 가장 잘 설계한 클래스라면 항목 18의 조언을 따라 “맞게 쓰기에는 쉽게, 틀리게 쓰기에는 어렵게” 만들어져야 할 것이다. 늘 그런 것은 아니지만, 암시적 변환보다는 get 등의 명시적 변환 함수를 제공하는 쪽이 나을 떄가 많다. 원하지 않는 타입 변환이 일어날 여지를 줄여주는 것은 확실하니까 말이다. 하지만 암시적 타입 변환에서 생기는 사용 시 자연스러움이 빛을 발하는 경우도 있다는 점도 알아두면 좋다.
이번 항목을 읽으면서 이런 생각이 들 수 있을 것이다. RAII 클래스에서 자원 접근 함수를 열어 주는 설계가 혹시 캡슐화에 위배되는 것은 아닌지 하고 말이다. 솔직히 그렇긴 하지만, 처음부터 틀려먹은 엉망진창 설계도 아니다. RAII 클래스는 애초부터 데이터 은닉이 목적이 아니다. 자원 해제라는 원하는 동작이 실수 없이 이루어지면 되는 것이 목적이다. 굳이 원한다면 ‘자원 해제’라는 기본 기능 위에 캡슐화 기능을 얹을 수는 있겠지만, 꼭 필요한 것은 아니다. 이야기가 나왔으니 말인데, 시중에 나와 있는 RAII 클래스 중에는 이미 자원의 엄격한 캡슐화와 느슨한 캡슐화를 동시에 지원하는 것들도 꽤 있다. tr1::shared_ptr이 대표적인 예인데, 이 클래스는 참조 카운팅 메커니즘에 필요한 장치들은 모두 캡슐화하고 있지만, 그와 동시에 자신이 관리하는 포인터에 쉽게 접근할 수 있는 통로도 여전히 제공하고 있다. 꼼꼼히 제대로 설계된 클래스가 그렇듯, 사용자가 볼 필요가 없는 데이터는 가리지만 고객 차원에서 꼭 접근해야 하는 데이터는 열어 주는 것이다.
실제 자원을 직접 접근해야 하는 기존 API들도 많기 때문에, RAII 클래스를 만들 때는 그 클래스가 관리하는 자원을 얻을 수 있는 방법을 열어 주어야 한다.
자원 접근은 명시적 변환 혹은 암시적 변환을 통해 가능하다. 안전성만 따지면 명시적 변환이 대체적으로 더 낫지만, 고객 편의성을 놓고 보면 암시적 변환이 괜찮다.
항목 16 : new 및 delete를 사용할 때는 형태를 반드시 맞추자.
아래에 적어 놓은 것에서 뭔가 잘못된 점이 보이는가?
1
2
3
std::string* stringArray = new std::string[100];
...
delete stringArray;
가장 신경 쓰이는 new와 delete가 짝이 맞아 있다. 코드가 위와 같이 되어 있으면 그 프로그램은 미정의 동작을 보이게 된다. stringArray가 가리키는 100개의 string 객체들 가운데 99개는 정상적은 소멸 과정을 거치지 못할 가능성이 크다. 그도 그럴 것이, 위 코드로는 소멸자가 99번 불릴 턱이 없기 때문이다.
우리가 new 연산자를 사용해서 표현식을 꾸미게 되면, 이로 인해 두 가지 내부 동작이 진행된다. 일단 메모리가 할당된다(이때 operator new라는 이름의 함수가 쓰인다). 그 다음, 할당된 메모리에 대해 한 개 이상의 생성자가 호출된다. delete 표현식을 쓸 경우에는 또 다른 두 가지의 내부 동작이 진행되는데, 우선 기존에 할당된 메모리에 대해 한 개 이상의 소멸자가 호출되고, 그 후에 메모리가 해제된다(이때 operator delete라는 이름의 함수가 쓰인다). 여기서 delete 연산자가 적용되는 객체는 소멸자가 호출하는 횟수이다!
풀어서 설명해보자면, 삭제되는 포인터는 객체 하나만 가리킬까 아니면 객체의 배열을 가리킬까? 이것이 핵심인데, 왜냐하면 new로 힙에 만들어진 단일 객체의 메모리 구조(layout)는 객체 배열에 대한 메모리 배치구조와 다르기 때문이다. 특히, 배열을 위해 만들어지는 힙 메모리에는 대개 배열원소의 개수가 박혀 들어간다는 점이 가장 결정적인데, 이 때문에 delete 연산자는 소멸자가 몇 번 호출될지를 쉽게 알 수 있다. 반면, 단일 객체용 힙 메모리는 이런 정보가 없다. ‘배치구조가 다르다’ 라는 말 뜻은 다음과 같이 생각하면 될 것이다.
여기서 n은 배열의 크기(원소의 개수)이다.
| 한 개의 객체 : | object | ||||
| 객체의 배열 : | n | object | object | … | object |
위 그림은 그냥 예제이다. 컴파일러마다 꼭 저런 식으로 구현할 필요는 없다. 사실 대다수의 경우에 그렇다.
어떤 포인터에 대해 delete를 적용할 때, delete 연산자로 하여금 ‘배열 크기 정보가 있다’는 것을 알려 줄 칼자루는 우리가 쥐고 있다. 우리가 하지 않으면 아무도 모른다. 이때 대괄호 쌍([])을 delete 뒤에 붙여 주는 것이다. 그제야 delete가 ‘포인터가 배열을 가리키고 있구나’라고 가정하게 된다. 그렇지 않으면 그냥 단일 객체라고 간주하고 만다.
1
2
3
4
5
6
7
std::string* stringPtr1 = new std::string;
std::string* stringPtr2 = new std::string[100];
...
delete stringPtr1;
delete[] stringPtr2;
stringPtr1에 [] 형태를 사용하면 어떤 일이 생길까? 딱 정해진 것은 아니지만, 결과가 별로 아름답지 않을 것이다. 메모리 배치구조가 위와 같이 되어 있다고 가정할 때, 우선 delete는 앞쪽의 메모리 몇 바이트를 읽고 이것을 배열 크기라고 해석한다. 이윽고 배열 크기에 해당하는 횟수만큼 소멸자를 호출하기 시작할 테다. 그러다 결국 자신이 밟고 있는 메모리가 배열에 속해 있지도 않다는 사실은 물론, 그 메모리에는 자신이 소멸시키려는 타입의 객체가 이미 들어 있지 않다는 명백한 사실에 도달할 것이다.
그렇다면 stringPtr2에 [] 형태를 사용하지 않으면 어떤 일이 생길까? 역시 정의된 바는 없으나 우리가 겪게 될 사태의 원인이 ‘소멸자 호출 횟수가 너무 적어서’라는 점은 분명하다. 심지어 int 등의 기본제공 타입이라 해도 이들의 배열에 대해 []를 쓰지 않으면 미정의 동작이 나타난다. 소멸자도 없는 주제에 말이다.
어려울 것이 하나도 없다. new 표현식에 []를 썼으면, 여기에 대응되는 delete 표현식에도 []를 써야 한다는 아주 간단한 규칙이다. new 표현식에 []를 쓰지 않았으면, delete 표현식에도 []를 안 쓰면 된다.
동적 할당된 메모리에 대한 포인터를 멤버 데이터로 갖고 있는 클래스를 만드는 중이며 이 클래스에서 제공하는 생성자도 여러 개일 경우에 특히 이 규칙을 뼛속 깊이 간직하자. 왜냐하면 포인터 멤버를 초기화하는 부분인 생성자에서 new 형태를 똑같이 맞출 수 밖에 없기 때문이다. 이렇게 하지 않는다면, 소멸자에서 어떤 형태의 delete를 써야 할 지 어떻게 알겠는가?
이 규칙은 typedef 애호가들도 알아둘 가치가 있다. typedef로 정의된 어떤 타입의 객체(배열)를 메모리에 생성하려고 new를 썼을 때 나중에 어떤 형태의 delete를 적어줘야 하는가에 대한 언급을 달아 주는 책임을 해당 typedef의 작성자가 져야 한다고 압박을 가하는 의미로 볼 수 있다. 예를 들어 어떤 typedef 타입이 다음과 같이 되어 있다고 가정하자.
1
typedef std::string AddressLines[4];
AddressLines는 보다시피 배열이다. 따라서 아래처럼 new를 사용하면,
1
2
// new AddressLines는 string*를 반환한다. new string[4]이니까.
std::string* pal = new AddressLines;
delete 역시 배열 형태가 되어야 한다.
1
delete[] pal;
머리 속이 심난해지지 않으려면, 배열 타입을 typedef 타입으로 만들지 않는 것이 좋다. 배열이 필요하면 vector나 array를 사용하자.
new 표현식에 []를 썼으면, 여기에 대응되는 delete 표현식에도 []를 써야 한다. new 표현식에 []를 쓰지 않았으면, delete 표현식에도 []를 안 쓰면 된다.
항목 17 : new로 생성한 객체를 스마트 포인터에 저장하는 코드는 별도의 한 문장으로 만들자.
처리 우선순위를 알려 주는 함수가 하나 있고, 동적으로 할당한 Widget 객체에 대해 어떤 우선순위에 따라 처리를 적용하는 함수가 하나 있다고 가정하자.
1
2
int priority();
void processWidget(std::tr1::shared_ptr<Widget> pw, int priority);
자원 관리에는 객체를 사용하는 것이 좋다는 걸 배웠으니, processWidget 함수는 동적 할당된 Widget 객체에 대해 스마트 포인터를 사용하도록 만들어졌다.
이렇게 만들어진 processWidget 함수를 이제 호출한다.
1
processWidget(new Widget, priority());
이 코드는 컴파일이 안된다. 포인터를 받는 tr1::shared_ptr의 생성자는 explicit으로 선언되어 있기 때문에, ‘new Widget’ 표현식에 의해 만들어진 포인터가 tr1::shared_ptr 타입의 객체로 바꾸는 암시적 변환이 있을 리가 없기 때문이다. processWidget에는 tr1::shared_ptr이 필요한데 말이다. 반면, 아래의 코드는 컴파일이 된다.
1
processWidget(std::tr1::shared_ptr<Widget>(new Widget), priority());
그런데 여기에는 또 이해할 수 없는 사실이 숨어 있다. 어디서든 잘 쓰고 있는 자원 관리 객체를 쓰고 있는데도, 이 문장은 자원을 흘릴 가능성이 있다는 것이다. 어째서 그런지 알아보자.
컴파일러는 processWidget 호출 코드를 만들기 전에 우선 이 함수의 매개변수로 넘겨지는 인자를 평가하는 순서를 밟는다. 여기서 두 번째 인자는 priority 함수의 호출문 밖에 없지만, 첫 번째 인자 tr1::shared_ptr
- new Widget 표현식을 실행하는 부분
- tr1::shared_ptr 생성자를 호출하는 부분
사정이 이렇기 때문에, processWidget 함수 호출이 이루어지기 전에 컴파일러는 다음의 세 가지 연산을 위한 코드를 만들어야 한다.
- priority를 호출한다.
- new Widget을 실행한다.
- tr1::shared_ptr 생성자를 호출한다.
그런데, 여기서 각각의 연산이 실행되는 순서는 컴파일러 제작사마다 다르다는 게 문제이다. C++ 컴파일러의 경우엔 이들의 순서를 정하는 데 있어서 상당한 자유도를 갖고 있다. new Widget 표현식은 tr1::shared_ptr 생성자가 실행될 수 있기 전에 호출되어야 한다. 왜냐하면 이 표현식의 결과가 tr1::shared_ptr 생성자의 인자로 넘어가니까 당연한거다. 그러나 priority의 호출은 처음 호출될 수도 있고, 두 번째나 세 번째에 호출될 수도 있다. 만일 어떤 컴파일러에서 두 번째라고 정했다면 연산 순서는 다음과 같이 결정된다.
- new Widget을 실행한다.
- priority를 호출한다.
- tr1::shared_ptr 생성자를 호출한다.
하지만 priority 호출 부분에서 예외가 발생했다면 어떻게 될지 생각해 보자. new Widget으로 만들어졌던 포인터가 유실될 수 있을 것이다. 자원 누출을 막아 줄 줄 알고 준비한 tr1::shared_ptr에 저장되기도 전에 예외가 발생했으니까 말이다. 그러니까 processWidget 호출 중에 자원이 누출될 가능성이 있는 이유는, 자원이 생성되는 시점과 그 자원이 자원 관리 객체로 넘어가는 시점 사이에 예외가 끼어들 수 있기 때문이다.
이런 문제를 피해 가는 방법은 간단하다. Widget을 생성해서 스마트 포인터에 저장하는 코드를 별도의 문장 하나로 만들고, 그 스마트 포인터를 processWidget에 넘기는 것이다.
1
2
std::tr1::shared_ptr<Widget> pw(new Widget);
processWidget(pw, priority());
한 문장 안에 있는 연산들보다 문장과 문장 사이에 있는 연산들이 컴파일러의 재조정을 받을 여지가 적기 때문에 위의 코드는 자원 누출 가능성이 없다. 고쳐진 코드를 보면 new Widget 표현식과 tr1::shared_ptr 생성자는 한 문장에 들어 있고, priority를 호출하는 코드는 별도의 문장에 있다. 그렇기 때문에 컴파일러가 priority 호출을 둘 사이로 옮기고 싶어도 허용이 안 되는 것이다.
new로 생성한 객체를 스마트 포인터로 넣는 코드는 별도의 한 문장으로 만들자. 이것이 안 되어 있으면, 예외가 발생할 때 디버깅하기 힘든 자원 누출이 초래될 수 있다.
make_shared
이제는 항목 17에서 설명한 문제를 해결하기 위해 make_shared를 사용하면 된다.
1
processWidget(std::make_shared<Widget>(), priority());
위 코드처럼 한줄로 써도 make_shared 내부에서 new와 스마트 포인터 생성이 원자적으로 일어나므로, priority() 호출 위치와 상관없이 예외 발생 시 자원 누출이 봉쇄된다.
참고