이 글은 『Effective C++』를 읽고 개인적으로 공부한 내용을 정리한 기록입니다.
저는 컴퓨터공학을 전공하지 않았으며, 프로그래밍을 공부하는 과정에서의 이해와 생각을 정리하기 위해 글을 작성하고 있습니다.
따라서 내용 중 일부에 오류나 부정확한 설명이 있을 수 있으며, 피드백은 언제든지 환영합니다. 확인 후 수정하도록 하겠습니다.
전문적인 해설이 아닌 개인적 시선에서의 정리임을 참고하고 읽어주시면 감사하겠습니다.
[Effective C++ 정리 #14] 리소스 관리 클래스에서 복사 동작을 신중히 결정
이전 아이템 #13에서는 C++의 자원 관리에서 RAII(Resource Acquisition Is Initialization) 패턴이 얼마나 강력한지를 살펴보았습니다. RAII 덕분에 우리는 자원을 직접 관리하는 부담에서 벗어나고, 소멸자에 자원 해제를 맡기면서 예외에도 안전한 코드를 작성할 수 있습니다.
하지만 RAII 클래스를 설계하다 보면 반드시 마주하게 되는 중요한 질문이 하나 있습니다. "RAII 객체를 복사할 수 있도록 허용할 것인가?" 입니다.
이번 아이템에서는 자원을 관리하는 클래스에서 복사 동작을 어떻게 결정해야 하는지, 복사가 필요한 경우 어떤 방식으로 구현해야 하는지 자세히 살펴봅니다.
RAII 클래스 복사의 4가지 전략을 소개합니다
RAII 클래스에서 복사 동작을 설계할 때는 크게 4가지 전략 중에서 하나를 선택합니다:
- 복사를 아예 금지 (가장 안전함)
- 참조 카운팅(reference counting)으로 복사
- 자원을 깊은 복사(deep copy)
- 소유권을 이전(transfer ownership)
각 방법은 다루는 자원의 특성에 따라 적절히 선택해야 합니다. 아래에서 자세히 살펴보겠습니다.
1. 복사를 금지하는 방식
많은 경우 자원을 관리하는 클래스는 복사 자체가 의미가 없습니다. 예를 들어, 뮤텍스(mutex) 같은 동기화 객체는 복사해봤자 쓸모가 없습니다. 복사본이 있다고 해서 두 개의 독립적인 락이 생기는 것이 아니기 때문입니다.
다음은 간단한 RAII 클래스 예제입니다:
class Mutex { ... };
void lock(Mutex* pm);
void unlock(Mutex* pm);
class Lock {
public:
explicit Lock(Mutex* pm) : mutexPtr(pm) { lock(mutexPtr); }
~Lock() { unlock(mutexPtr); }
private:
Mutex* mutexPtr;
};
위 Lock
객체는 스코프 내에서 생성되자마자 락(lock())을 하고, 스코프가 끝날 때 자동으로 락을 해제(unlock())합니다. 이 Lock
객체를 복사할 수 있게 만들면 큰 문제가 발생합니다.
따라서 이런 클래스는 복사 생성자와 대입 연산자를 아예 금지합니다. 이것은 이전 아이템 #6에서 배운 방식(Uncopyable 상속)으로 쉽게 구현할 수 있습니다:
class Lock: private Uncopyable {
public:
explicit Lock(Mutex* pm) : mutexPtr(pm) { lock(mutexPtr); }
~Lock() { unlock(mutexPtr); }
private:
Mutex* mutexPtr;
};
이렇게 하면 컴파일 단계에서 복사를 금지하여 안전성을 보장할 수 있습니다.
2. 참조 카운팅 방식 (shared_ptr의 방식)
다중 객체가 자원을 공유해야 하는 경우에는 **참조 카운팅(reference counting)** 이 적합합니다. 이 방식에서는 복사할 때 소유권을 공유하고, 마지막 소유자가 소멸될 때 자원을 해제합니다.
가장 대표적인 예가 바로 shared_ptr
입니다.
std::shared_ptr<Investment> pInv1(createInvestment());
std::shared_ptr<Investment> pInv2(pInv1); // 참조 카운트 증가
하지만 이 방식을 임의의 RAII 클래스에 적용할 수도 있습니다. 예를 들어 Lock
클래스를 참조 카운팅으로 확장한다고 가정해 봅니다:
class Lock {
public:
explicit Lock(Mutex* pm)
: mutexPtr(pm, unlock) // unlock을 deleter로 지정
{ lock(mutexPtr.get()); }
private:
std::shared_ptr<Mutex> mutexPtr;
};
shared_ptr
는 소멸 시 자동으로 deleter를 호출하므로 unlock 호출도 보장됩니다. 따라서 이제 복사해도 문제없이 소유권을 공유할 수 있습니다.
다만 이 방법이 항상 적절한 것은 아닙니다. 뮤텍스처럼 독점적인 자원은 공유 자체가 어색할 수 있으므로 주의가 필요합니다.
3. 깊은 복사 (deep copy)
얕은 복사와 깊은 복사에 대한 기초적인 내용은 여기(https://fictitious.tistory.com/30)에서 소개한 적이 있습니다.
자원의 복사본을 새롭게 만들어야 하는 경우라면 **깊은 복사**가 적합합니다. 이 방식에서는 객체 복사 시 자원도 새로 할당하여 독립적인 복사본을 만듭니다.
예를 들어, 문자열 클래스는 일반적으로 깊은 복사를 수행합니다:
class MyString {
public:
MyString(const char* p) {
data = new char[strlen(p) + 1];
strcpy(data, p);
}
MyString(const MyString& rhs) {
data = new char[strlen(rhs.data) + 1];
strcpy(data, rhs.data);
}
~MyString() { delete[] data; }
private:
char* data;
};
이 방식은 복사된 두 객체가 전혀 독립적으로 행동하기 때문에 복사 후 수정해도 서로 영향을 주지 않는다는 장점이 있습니다.
반면, 복사 비용이 클 수 있으며, 예외 발생 시 자원 할당과 해제를 적절히 처리해야 한다는 부담이 있습니다.
4. 소유권 이전 (transfer ownership)
마지막으로 auto_ptr
가 제공했던 **소유권 이전 방식**도 있습니다. 복사할 때마다 기존 객체는 null이 되고, 새 객체가 자원을 독점하게 만듭니다.
이 방식은 공유를 금지하면서도 소유권 이동을 허용할 때 사용됩니다. C++11 이후 등장한 unique_ptr
가 이 개념을 깔끔하고 안전하게 구현합니다.
std::unique_ptr<Investment> pInv1(createInvestment());
std::unique_ptr<Investment> pInv2 = std::move(pInv1); // 소유권 이동
복사는 불가능하지만 이동(move)은 허용되므로 불필요한 복사 비용 없이 소유권 이전이 가능합니다. 실제로 현대 C++에서는 이 방식이 가장 많이 사용됩니다.
핵심 요약
- RAII 클래스의 복사 동작은 클래스마다 신중히 결정해야 한다.
- 일반적으로 참조 카운팅 방식을 사용하지만, 복사를 금지할지, 깊은 복사를 할지, 소유권을 이전할지 다른 전략도 존재한다.
감사합니다.
'프로그래밍 > C\C++' 카테고리의 다른 글
[Effective C++ 정리 #16] new와 delete 사용 시 유의 점 - 괄호 하나로 무너지는 코드의 안정성 (0) | 2025.06.26 |
---|---|
[Effective C++ 정리 #15] RAII의 Raw resource 접근 방법 (2) | 2025.06.25 |
[Effective C++ 정리 #13] RAII란 무엇인가? RAII로 자원 관리 방법 (2) | 2025.06.23 |
[Effective C++ 정리 #12] 복사 생성자와 복사 대입 연산자 직접 작성 시 유의할 점 (0) | 2025.06.22 |
C/C++ 얕은 복사 및 깊은 복사 내용 정리 (2) | 2025.06.21 |