C++ 이야기 다섯번째: 내 객체 복사하지마!

Posted at 2007. 3. 13. 07:00 // in S/W개발/C++ 이야기 // by 김윤수


[이글의 최신 Update 문서는 항상 여기에서 확인할 수 있습니다]

C++ 이야기 다섯번째입니다. 개발자가 아니신 분들이나 C++ 로 주로 개발하지 않으시는 분들은 별로 관심이 가는 내용이 아닐 것 같네요.

이번 이야기에서는 복사에 대해 차분히 생각해 봤으면 합니다.

C++ 에서 복사 문제가 불거져 나오는 건 모든 사용자 타입을 Reference로 처리하는 Java 와 달리 포인터라는 클래스가 있어서 그렇다라는 건 대략 이해하고 계실 겁니다.

클래스안에서 멤버 변수로 포인터를 쓰면, 복사할 때 포인터가 가리키는 대상을 복사해야 하느냐 마느냐의 문제가 수면위로 떠 오르거든요. 복사하자니 시간과 메모리가 너무 아깝고(가리키는 대상이 클수록) 복사 안하자니 여기 저기 구멍이 뚫린 듯 문제가 불거지고. 거기다 복사 문제를 신경쓰지 않고, 컴파일러에게 맡겨 두면, 아무 생각 없이 포인터 값만 복사하니 그로 인해 초래되는 결과는 대략 난감합니다.

또, 꼭 포인터에 대해서만 이런 문제가 있는 것이 아니라 OS에서 관리하는 자원을 추상화시킨 자원 클래스인 경우에도 복사한다는 것 자체가 말이 안되는 경우-예를 들어, thread, lock, semaphore를 복사하는 경우-도 많이 있습니다.

이런 복잡한 이유 때문에 C++ 이야기 두번째에서 객체 복사 방법에 대해 몇 가지 대안들을 말씀드린 적이 있습니다. 자~ 다시 기억을 상기시켜 보세요.

1. 복사를 원천적으로 막아 버린다
2. 가리키는 객체에 대해 참조 카운팅을 수행합니다
3. 관리하고 있는 객체를 진짜로 복사합니다. 소위 deep copy 라고도 하죠.
4. 복사할 때 관리하고 있는 객체의 소유권을 옮겨 버립니다.

2번에 대해서는 C++ 이야기 네번째에서 shared_ptr을 소개 드렸고, 4번에 대해서는 C++ 첫번째, 두번째 이야기에서 말씀드렸습니다. 3번은 어차피 워낙 명확하니까 말씀드리진 않겠습니다.

그럼 남은 건 1번인데요... 이건 여러분이 작성하고 있는 클래스가 명확하게 복사라는 동작이 허용돼서는 안되는 경우에 쓰실 수 있는 방법입니다.

이제부터 차근 차근 알아보도록 하겠습니다.

여러분 생각에 복사를 금지할 수 있는 가장 쉬운 방법은 어떤 게 떠오르시나요 ? 저는 다음과 같은 방법이 제일 먼저 떠오르더군요.

class BigClass {
public:
  BigClass(int i = 0): m_i(i) {}  // 정상적인 생성자
  BigClass(const BigClass& other) { assert(!"Do not use copy constructor. copy is not allowed for BigClass"); }
  BigClass& operator=(const BigClass& other) { assert(!"Do not use copy assignment. copy is not allowed for BigClass"); }

private:
  int m_i;
};

어떤가요 ? 위와 같이하면 실행시에 BigClass에 대한 복사가 일어나게 되면, 바로 assert가 수행되면서 프로그램 실행이 취소됩니다. 그리고, assert 에 의해 소스코드명과 라인까지 찍히게 되니까, 대부분 개발하는 동안 프로그래머가 바로 알 수 있게 됩니다.

다음과 같은 예제를 컴파일한 후 한 번 실행시켜 보세요.

// assert()를 쓰기 위해 include 합니다.
#include <assert.h>

class BigClass { ...... };

int main(void)
{
  BigClass bc1;
  BigClass bc2(bc1);                        // 오~ 이런 복사 생성자를 호출하고 있군요.
  BigClass* pbc = new BigClass;

  bc1 = *pbc;                               // 복사 대입 연산자를 호출하네요.

  return -1;
}

그럼 다음과 비스므레한 결과를 얻으실 겁니다.(Linux 에서 g++ 3.2.2로 컴파일해서 실행한 결과입니다)

noncopy: noncopy.cpp:8: BigClass::BigClass(const BigClass&): Assertion `!"Do not use copy constructor. copy is not allowed for BigClass"' failed.
Aborted

어떻습니까 ? "내가 쓰지 말라고 했잖아~"라는 여러분의 의도는 명확히 전달했네요. 제 말투에서 왠지 부정적인 뉘앙스가 느껴지시나요? 왜 그럴까요 ?

여러분의 의도는 좌우당간 명확하게 전달했지만, 위와 같은 코드가 정말 여러분 코드의 깊숙한 어딘가에 또아리를 틀고 있었는데, 마침 단위 테스트, 통합 테스트, 시스템 테스트, 심지어 인수 테스트까지 거치는 동안 전혀 발견되지 않다가 제품이 출시된 이후에 문제가 터졌다면 어떻게 될까요 ? 게다가 assert()를 시스템이 기본 제공하는 걸 쓰지 않고, 화면에 예쁘게 GUI로 메시지를 출력하게 해 놓았는데, 그 메시지를 세상에~ 개발자가 "Fuck you!!"라고 해둔 겁니다.-가끔가다 이런 개념 없는 일들이 벌어지곤 하죠. 개발자가 여기까지 코드가 올리 없다라고 생각하면서 작성한 코드에. 제가 좀 과장을 하긴 했지만, 갑자가 정신이 혼미해 지실 겁니다.

이런 약간 비현실적인 얘기는 차치하고서라도 소프트웨어공학에서 잘 알려진 사실 중 하나 "개발 단계의 나중에 Defect을 발견할수록 Defect 제거 비용이 커진다"라는 건 익히 알고 계실 겁니다. 제품 출시된 이후, Defect이 발견돼서 그 제품들을 회수해서 교체해 주는-혹시라도 S/W 업데이트 방법이 있다면 A/S 엔지니어가 A/S 요청이 들어왔을 때 돌아다니는-비용을 생각하신다면 이건 거의 목이 달아날 사항일 것입니다.(문제의 심각성을 파악하시라고 제가 좀 협박하고 있습니다)

머리가 조금이라도 돌아가시는 분은 이제 딴 길을 모색하기 시작하실 겁니다. 컴파일할 때나 링크할 때 에러가 나게 할 방법은 없나 ? 하고 말입니다.

여기서 잠깐 제가 이전에 얘기했던 말을 상기시켜 볼까요 ? "컴파일러가 기본으로 생성하는 복사 생성자와 복사 대입 연산자는 public 인터페이스이다" 조금만 생각해 보면 당연하겠지요 ? 그래야 다른 코드에서 쓸 수 있으니까요. 여기에 생각이 다다르면 자연스럽게 떠오르는 방법이 있을 겁니다. 그렇습니다. 복사 생성자와 복사 대입 연산자를 private으로 선언해 버리는 겁니다.

class BigClass {
public:
  BigClass(int i = 0): m_i(i) {}  // 정상적인 생성자

private:
  // private으로 선언하고 있다는 것에 주목하시기 바랍니다
  BigClass(const BigClass& other) { assert(!"Do not use copy constructor. copy is not allowed for BigClass"); }
  BigClass& operator=(const BigClass& other) { assert(!"Do not use copy assignment. copy is not allowed for BigClass"); }

  int m_i;
};


의기양양해 하는 여러분의 모습이 안 봐도 비디오입니다. 이렇게 하면 당연히 컴파일시에 컴파일 에러가 발생하게 될 것입니다. 여러분이 작성한 BigClass를 쓰는 프로그래머는 컴파일시에 문제를 보고 해결할 수 있게 되구요. 실제로 Linux에서 g++ 3.2.2 로 컴파일 해 봤더니 다음과 같은 결과를 내뱉네요(마치 못 먹을 걸 먹은 듯 제 소스코드를 그냥 내뱉네요. 기분 별로 좋지 않습니다.)

noncopy2.cpp: In function `int main()':
noncopy2.cpp:10: `BigClass::BigClass(const BigClass&)' is private
noncopy2.cpp:22: within this context
noncopy2.cpp:14: `BigClass& BigClass::operator=(const BigClass&)' is private
noncopy2.cpp:25: within this context

축하드립니다. 여러분. 드뎌 해 내셨습니다. 여러분의 의도를 컴파일시에 확실히 보여주고 있습니다. 소스코드 코멘트나 매뉴얼에도 복사 생성자와 복사 대입 연산자를 private 으로 선언한 이유를 명확하게 나타내시면 더욱 좋을 것입니다.

음... 근데 아직도 저기서 몇 분이서 아직 2% 부족하다며 손가락으로 안돼 안돼를 연발하고 계시군요. 전 솔직히 이정도면 됐지라고 생각하는데... 그분들 의견 좀 들어보죠. 뭐라구요 ? friend 가 어떻다구요 ? friend 클래스/함수는 여전히 복사 생성자와 복사 대입 연산자를 쓸 수 있다구요 ? 어~ 그러네요.

여기부터는 제 한계를 넘어서기 시작하네요. 그렇잖아도 나이 들어 안 돌아가는 머리 돌아가실 지경이네요. 그냥 그 유명한 Scott Meyers 씨에게 물어 보기로 했습니다. 그랬더니 친절하게 답변해 주시더군요(제가 좀 Scott Meyers씨와 막역한 사이입니다. 그 분 책을 다 샀걸랑요)

복사 생성자와 복사 대입 연산자를 private으로 선언하고, 구현을 아예 정의하지 말아 버리라네요. 다음과 같이요.

class BigClass {
public:
  BigClass(int i = 0): m_i(i) {}  // 정상적인 생성자

private:
  // private으로 선언해 놓고, 구현해 놓지 않은 의도를 여기에 명확하게 표시해 두는 게
  // 나중에 maintainer에 대한 예의겠죠.
  BigClass(const BigClass&);
  BigClass& operator=(const BigClass&);

  int m_i;
};

오~ 이런 엘레강스한 솔루션이 있다니. 이번에는 friend 클래스/함수에서 쓰더라도 *링크*가 안 될 겁니다. 이제 구멍을 꽉꽉 막은 셈이네요. 근데, 아직도 0.2% 부족하답니다. 링크시에 에러가 나는 게 아쉽다네요. 링크랑 컴파일이랑 그게 그거 아닌가요 ? 소프트웨어 공학 고수왈 "프로젝트가 커지면 링크시에 에러가 나는 것도 Defect 제거 비용이 크니라"라고 하네요. 투덜 투덜. 그럼 나보고 어쩌라고. 이젠 별 생각 없습니다. 저작권법에 걸리던 말던 이젠 그냥 EC++ 책 내용 그대로 옮깁니다.

먼저 다음과 같은 클래스를 정의합니다.

class Uncopyable {
protected:                                  // 파생된 클래스에 대해
  Uncopyable() {}                           // 생성과 소멸을
  ~Uncopyable() {}                          // 허용합니다.

private:
  Uncopyable(const Uncopyable&);            // 하지만 복사는 방지합니다.
  Uncopyable& operator=(const Uncopyable&);
};

복사를 막고 싶은 클래스-우리 경우는 BigClass-정의를 다음과 같이 바꿉니다.

class BigClass: private Uncopyable {
  ......
};

이렇게 하면, 컴파일러가 BigClass 객체를 복사 생성하는 코드나 복사 대입하는 코드를 만나게 되면 복사 생성자와 복사 대입 연산자를 만들려고 시도할 겁니다. 근데, 컴파일러가 생성하는 복사 생성자와 복사 대입 연산자는 기본 클래스의 그것을 꼭 먼저 호출하게 되어 있습니다. 그렇지만 기본 클래스인 Uncopyable의 복사 생성자와 복사 대입 연산자는 private으로 선언되어 있으니, 상속받은 BigClass에서는 쓸 수 없게 되고, 컴파일이 안 되는 건 당연지사입니다. 헉헉~ 여기까지 따라오신 여러분 대~단하십니다.

마지막으로 한 가지 tip 알려 드리고, 이번 글을 마칠까 합니다.

boost::noncopyable 이라는 클래스가 정확히 위와 같은 Uncopyable을 구현해 놓고 있으니, 다음과 같이 그냥 갖다 쓰셔도 됩니다.

// 다음 파일을 include 합니다.
#include "boost/noncopyable.hpp" or #include "boost/utility.hpp"

// private 상속을 씁니다.
class BigClass: private boost::noncopyable {
public:
  BigClass(int i = 0): m_i(i) {}  // 정상적인 생성자

private:
  int m_i;
};

아니면 boost library를 인터넷에서 다운 받아서 컴파일하고 설치하느니 그냥 직접 작성하시던지요.

참고 문헌:
Effective C++ 3rd Edition 항목 6(Scott Meyers)
boost noncopyable class(클릭해 보세요)

소프트웨어 관련된 저의 다른 글들도 참고로 읽어 보세요.

소프트웨어는 soft 해야 제 맛이다
Flexible한 S/W 작성하기
소스코드 복사의 위험성
C++ 이야기 첫번째: auto_ptr 템플릿 클래스 소개
C++ 이야기 두번째: auto_ptr의 두 얼굴
C++ 이야기 세번째: new와 delete
C++ 이야기 네번째: boost::shared_ptr 소개
C++ 이야기 다섯번째: 내 객체 복사하지마!
C++ 이야기 여섯번째: 기본기 다지기(bool타입에 관하여)