본문 바로가기

객체 복사를 막는 이유와 객체 복사를 막는 방법

@iamrain2025. 12. 5. 21:03

객체 복사를 막는 이유와 객체 복사를 막는 방법

1. 왜 객체 복사를 제어해야 하는가?

C++에서 객체 복사는 매우 흔한 연산이지만, 때로는 개발자의 의도와 다르게 동작하거나 심각한 문제를 일으킬 수 있습니다. 컴파일러는 사용자가 복사 생성자나 복사 대입 연산자를 정의하지 않으면 자동으로 생성해주지만, 이 기본 동작이 항상 바람직한 것은 아닙니다. 따라서 특정 클래스에 대해 복사를 금지하거나 제어해야 하는 경우가 발생하며, 이는 안정적이고 효율적인 프로그램을 작성하기 위한 핵심적인 설계 결정 중 하나입니다.

객체 복사를 제어해야 하는 주된 이유는 다음과 같습니다.

  • 자원 소유권 문제: 객체가 파일 핸들, 네트워크 소켓, 동적 할당된 메모리 등의 시스템 자원을 관리할 때, 얕은 복사(shallow copy)가 일어나면 두 객체가 동일한 자원을 소유하게 됩니다. 이는 이중 해제(double free)나 자원 누수와 같은 심각한 버그로 이어질 수 있습니다.
  • 고유한 식별성(Unique Identity): 프로그램 내에서 단 하나만 존재해야 하는 객체(예: 특정 게임 캐릭터, 시스템 설정 관리자)의 경우, 복사는 객체의 고유성을 해치고 논리적 오류를 유발할 수 있습니다.
  • 성능 저하 방지: 크기가 큰 객체를 불필요하게 복사하면 상당한 성능 저하가 발생할 수 있습니다. 복사를 막음으로써 개발자가 참조나 포인터, 이동 시맨틱 등을 사용하도록 유도할 수 있습니다.
  • 슬라이싱(Slicing) 방지: 상속 관계에서 파생 클래스 객체를 기본 클래스 객체에 복사 대입하면 파생 클래스 부분이 잘려나가는 '슬라이싱' 문제가 발생합니다. 이를 방지하기 위해 기본 클래스의 복사를 막을 수 있습니다.

2. 객체 복사를 막는 방법

2.1. C++98/03: 복사 생성자와 복사 대입 연산자를 private으로 선언

C++11 이전에는 객체 복사를 막기 위해 복사 생성자와 복사 대입 연산자를 private 멤버로 선언하고 구현은 하지 않는 기법을 사용했습니다.

class NonCopyable {
private:
    // 복사 생성자와 복사 대입 연산자를 private으로 선언만 하고 구현하지 않는다.
    NonCopyable(const NonCopyable&);
    NonCopyable& operator=(const NonCopyable&);

public:
    NonCopyable() {}
};

동작 원리:

  1. 외부에서의 복사 시도: 클래스 외부에서 복사를 시도하면, 컴파일러는 private으로 선언된 복사 생성자/대입 연산자에 접근할 수 없으므로 컴파일 오류를 발생시킵니다.
  2. 내부(friend나 멤버 함수)에서의 복사 시도: 만약 friend 클래스나 멤버 함수 내에서 복사를 시도하면, private 멤버에 접근은 가능하지만, 함수가 구현되지 않았기 때문에 링커(linker)가 해당 함수의 정의를 찾지 못해 링크 오류를 발생시킵니다.

이 기법은 효과적이었기 때문에 boost::noncopyable과 같은 라이브러리에서 널리 사용되었습니다.

2.2. C++11: = delete 키워드 사용

C++11부터는 복사를 금지하는 훨씬 명확하고 세련된 방법이 도입되었습니다. 바로 = delete 키워드를 사용하는 것입니다.

class NonCopyable {
public:
    NonCopyable() = default;
    ~NonCopyable() = default;

    // 복사 생성자와 복사 대입 연산자를 삭제(delete)
    NonCopyable(const NonCopyable&) = delete;
    NonCopyable& operator=(const NonCopyable&) = delete;
};

동작 원리:

  • 컴파일러가 = delete로 표시된 함수를 사용하려는 코드를 만나면, 접근 제어(public/private)와 상관없이 즉시 컴파일 오류를 발생시킵니다. 이는 의도를 명확하게 드러내고, 링크 오류보다 진단하기 쉬운 컴파일 오류를 유발하므로 더 나은 방법입니다.

2.3. 비교: private 선언 vs. = delete

특징 private 선언 (C++98/03) = delete (C++11)
오류 발생 시점 컴파일 오류 (외부 접근) 또는 링크 오류 (내부 접근) 항상 컴파일 오류
의도의 명확성 복사를 막는 관용구(idiom)이지만, 처음 보는 사람은 의도를 파악하기 어려울 수 있음. = delete 구문 자체가 '삭제됨'을 명시적으로 나타내므로 의도가 매우 명확함.
적용 범위 멤버 함수에만 적용 가능. 모든 함수(멤버/비멤버), 특수 멤버 함수, 생성자 등에 적용 가능하여 활용도가 높음.
진단 메시지 "private 멤버에 접근할 수 없습니다" 또는 "정의되지 않은 참조" 등 문맥에 따라 다름. "삭제된 함수를 호출하려고 합니다" 와 같이 명확한 오류 메시지를 제공함.
권장 사용 레거시 코드베이스를 유지보수할 때. C++11 이상을 사용하는 모든 현대 C++ 프로젝트에서 항상 권장됨.

결론적으로, 현대 C++에서는 객체 복사를 막기 위해 항상 = delete를 사용해야 합니다.

3. 동작 계층 구조

객체 복사 방지 메커니즘은 전적으로 C++ 언어 명세와 컴파일러/링커에 의해 구현되는 컴파일 타임 또는 링크 타임 기능입니다. 운영체제나 하드웨어 수준에서는 이를 인지하지 못합니다.

  1. 사용자 코드 영역 (User Code)
    • 개발자가 MyObject obj2 = obj1; 과 같이 객체 복사를 시도합니다.
  2. 컴파일러 영역 (Compiler)
    • 컴파일러는 이 코드를 분석하여 MyObject의 복사 생성자 MyObject(const MyObject&)를 호출하는 코드를 생성하려고 합니다.
    • = delete의 경우: 컴파일러는 함수 시그니처를 확인하는 과정에서 해당 함수가 delete된 것을 발견합니다. 즉시 "삭제된 함수를 사용하려 했다"는 내용의 컴파일 오류를 발생시키고 컴파일을 중단합니다.
    • private의 경우:
      • 외부 호출: 컴파일러는 접근 지정자를 확인하고, private 멤버이므로 접근할 수 없다는 컴파일 오류를 발생시킵니다.
      • 내부/friend 호출: 컴파일러는 접근을 허용하고, 해당 함수 호출 코드를 생성합니다. 컴파일은 성공적으로 끝납니다.
  3. 링커 영역 (Linker)
    • 컴파일러가 생성한 오브젝트 파일들을 연결하여 최종 실행 파일을 만드는 단계입니다.
    • private + 내부 호출의 경우: 링커는 컴파일러가 생성한 함수 호출 코드가 참조하는 함수(private 복사 생성자)의 구현부(정의)를 찾으려고 합니다. 하지만 개발자가 의도적으로 구현을 제공하지 않았으므로, 링커는 해당 심볼을 찾지 못해 "정의되지 않은 참조(undefined reference)" 오류를 발생시키고 링크를 중단합니다.

이처럼 = delete는 문제를 더 빠르고 명확하게 진단할 수 있게 해주므로 훨씬 우수한 방법입니다.

4. 구술형 요약

Q: 객체 복사를 왜 막아야 하나요?
A: 크게 네 가지 이유가 있습니다. 첫째, 파일이나 메모리 같은 자원을 관리하는 객체를 그냥 복사하면 자원 소유권이 모호해져 이중 해제 같은 심각한 버그가 생길 수 있습니다. 둘째, 게임 캐릭터처럼 세상에 단 하나만 있어야 하는 객체의 고유성을 보장하기 위해서입니다. 셋째, 큰 객체를 불필요하게 복사할 때 발생하는 성능 저하를 막기 위함입니다. 마지막으로, 상속 관계에서 파생 클래스의 정보가 잘려나가는 '슬라이싱' 현상을 방지하기 위해서입니다.

Q: C++에서 객체 복사를 막는 방법에는 어떤 것들이 있나요?
A: 전통적인 C++98 방식과 현대적인 C++11 방식이 있습니다. C++98에서는 복사 생성자와 복사 대입 연산자를 private으로 선언하고 구현하지 않는 방법을 썼습니다. 이렇게 하면 외부에서는 접근이 안 돼서 컴파일 오류가 나고, 내부에서 실수로 써도 구현체가 없어서 링크 오류가 발생합니다.

Q: C++11에서는 어떤 더 좋은 방법이 있나요?
A: 네, C++11부터는 = delete라는 키워드가 도입되었습니다. 복사 생성자와 복사 대입 연산자 뒤에 = delete;라고 붙여주면 됩니다. 이 방법은 복사가 금지되었다는 의도를 코드에 명확히 드러낼 수 있고, 언제나 링크 오류가 아닌 컴파일 오류를 발생시켜 문제를 더 빨리 발견하게 해줍니다. 따라서 현대 C++에서는 항상 = delete를 사용하는 것이 좋습니다.

iamrain
@iamrain :: Annals of Unreal

iamrain 님의 블로그 입니다.

공감하셨다면 ❤️ 구독도 환영합니다! 🤗

목차