C++에서는 객체를 복사할 때 복사 생성자와 복사 대입 연산자가 사용된다.
하지만 모든 객체가 안전하게 복사될 수 있는 것은 아니다.
예를 들어 특정 객체는 복사되면 비용이 크거나,
내부 자원 관리 문제로 인해 의도하지 않은 오류가 발생할 수 있다.
따라서 오늘은 객체의 복사를 막는 방법과 그 이유를 정리해보려고 한다.
객체 복사를 막는 방법
C++11 이후에는 복사 생성자와 복사 대입 연산자에 delete를 명시해서 객체 복사를 막을 수 있다.
class A {
public:
A() = default;
A(const A&) = delete;
A& operator=(const A&) = delete;
};
위 코드에서
A(const A&) = deleteA& operator=(const A&) = delete
를 통해 복사 생성과 복사 대입을 모두 막았다.
이렇게 하면 아래와 같은 코드는 컴파일되지 않는다.
A a;
A b = a; // 복사 생성 불가
A c;
c = a; // 복사 대입 불가
C++11 이전 방식
C++11 이전에는 delete 문법이 없었기 때문에
복사 생성자와 복사 대입 연산자를 private 영역에 선언만 하고 정의하지 않는 방식으로 복사를 막았다.
class T {
private:
T(const T&);
T& operator=(const T&);
};
하지만 의도를 명확하게 알아보기 어렵고, friend 키워드를 사용하면 접근할 수 있기 때문에
C++11 이후엔 delete 키워드를 이용해서 복사를 막는 것이 권장된다.
복사 불가능한 멤버를 포함한 경우
클래스 내부에 복사 불가능한 멤버가 있으면 해당 클래스의 복사 연산이 자동으로 삭제될 수 있다.
대표적인 예시가 std::unique_ptr이다.
#include <memory>
class B {
private:
std::unique_ptr<int> data;
};
int main() {
B b;
//B c = b; // 컴파일러가 자동으로 복사를 삭제해서 컴파일 불가능
}
unique_ptr은 단독 소유권을 표현하는 스마트 포인터이므로 복사가 불가능하다.
즉, 복사를 막으려고 직접 delete를 작성하지 않아도
복사 불가능한 멤버가 있다면 클래스 전체의 복사가 제한될 수 있다.
객체 복사를 막는 이유
객체 복사를 막는 이유는 아래와 같이 정리할 수 있다.
복사 비용이 큰 경우
객체가 큰 데이터를 가지고 있다면 복사 비용이 커질 수 있다.
예를 들어 큰 배열, 이미지 데이터, 많은 문자열, 대용량 버퍼 등을 가진 객체라면
객체를 복사할 때마다 많은 메모리와 시간이 필요할 수 있다.
이런 경우 의도치 않은 복사가 발생하면 성능 저하로 이어질 수 있다.
따라서 복사가 필요하지 않은 객체라면 아예 복사를 막아 불필요한 비용을 방지할 수 있다.
자원 소유 문제가 있는 경우
객체가 raw pointer 형태로 동적 자원을 직접 소유하고 있다면 기본 복사 동작은 위험할 수 있다.
아래 예시 코드에서 복사로 인해 문제가 생기는 상황을 볼 수 있다.
#include <iostream>
#include <memory>
class Resource {
public:
Resource() {
std::cout << "생성자 진입\n";
data = new int(10);
}
~Resource() {
std::cout << "소멸자 진입\n";
delete data;
}
private:
int* data;
};
// 얕은 복사 문제 발생함, 정의되지 않은 동작!!
void Func(Resource resource) {
// 리소스 처리
}
int main() {
Resource a;
Func(a);
}
Func(a)를 호출하면 Resource 객체가 값으로 복사된다.
이때 기본 복사는 data 포인터 값만 복사하므로, 원본과 복사본이 같은 메모리를 가리키게 된다.
이후에 함수가 끝날 때 data가 가리키는 메모리를 해제하고, main이 끝날 때 다시 해당 메모리를 해제한다.
결과적으로 두 객체의 소멸자에서 같은 메모리를 두 번 해제하게 되어 정의되지 않은 동작이 발생할 수 있다.
이러한 문제가 얕은 복사로 인해 발생할 수 있다.
얕은 복사로 인해 발생할 수 있는 문제는 다음과 같다.
- 같은 자원을 두 번 해제하는 중복 해제 문제
- 한 객체가 자원을 해제한 뒤 다른 객체가 댕글링 포인터를 가지는 문제
- 소유권이 불명확해지는 문제
이런 문제를 막기 위해서는 다음과 같은 방법을 사용할 수 있다.
- 복사를 금지한다.
- 깊은 복사를 직접 구현한다.
- RAII와 스마트 포인터를 사용한다.
이동만 허용할 수도 있다
복사는 막되, 이동은 허용하고 싶은 경우도 있다.
예를 들어 자원의 소유권을 다른 객체로 넘기고 싶을 때는 복사보다 이동이 더 적절하다.
이 경우 복사 생성자와 복사 대입 연산자는 삭제하고, 이동 생성자와 이동 대입 연산자는 허용할 수 있다.
class Test {
public:
Test() = default;
// 복사 명시적으로 제거
Test(const Test&) = delete;
Test& operator=(const Test&) = delete;
// 이동 연산 허용
Test(Test&&) noexcept = default;
Test& operator=(Test&&) noexcept = default;
};
위 코드는 복사는 막고 이동만 허용하는 형태이다.
이동 연산에 noexcept를 붙이는 이유
이동 생성자와 이동 대입 연산자에는 보통 noexcept를 붙이는 것이 좋다.
T(T&&) noexcept = default;
T& operator=(T&&) noexcept = default;
noexcept는 이 함수가 예외를 던지지 않는다는 의미이다.
이동 연산에 noexcept를 붙이는 이유는 다음과 같다.
- 예외가 발생하지 않는다고 명시할 수 있다.
- 표준 컨테이너가 더 안전하게 이동 연산을 사용할 수 있다.
- 불필요한 복사를 줄이고 성능 최적화에 도움이 될 수 있다.
예를 들어 std::vector는 내부 버퍼를 재할당할 때 기존 원소들을 새 메모리로 옮겨야 한다.
이때 이동 생성자가 noexcept라면 vector는 복사 대신 이동을 선택할 수 있다.
만약 이동 생성자가 noexcept가 아니라면 vector는 예외 안전성을 위해
이동 대신 복사를 선택할 수 있고, 이로 인해 불필요한 성능 비용이 발생할 수 있다.
따라서 이동만 허용하는 객체라면 이동 생성자와 이동 대입 연산자에 noexcept를 붙이는 것이 좋다.
정리
객체 복사를 막는 방법은 다음과 같다.
- 복사 생성자, 복사 대입 연산자에
= delete를 명시한다. - C++11 이전에는
private에 선언만 하고 정의하지 않는 방식을 사용했다. unique_ptr처럼 복사 불가능한 멤버를 포함하면 클래스의 복사도 제한될 수 있다.
객체 복사를 막는 이유는 다음과 같다.
- 객체가 클 경우 복사 비용이 크다.
- raw pointer로 자원을 소유하는 객체는 얕은 복사 문제가 생길 수 있다.
또 복사는 막고 이동만 허용할 수도 있다.
- 이동 생성자와 이동 대입 연산자를 정의한다.
- 이동 연산에는
noexcept를 붙이는 것이 좋다.
마무리
C++ 클래스를 계속 다루다 보니 크기가 큰 객체나 자원을 소유하는 객체는 무작정 복사하는 것보다
이동하거나 참조로 다루는 편이 더 적절할 수 있다는 것을 느꼈다.
실제로 언리얼 엔진 코드에서도 복사를 막는 예시를 확인할 수 있었고,
복사를 허용할지 막을지는 객체의 소유권과 수명 관리에 관련된 중요한 설계라는 생각이 들었다.
앞으로 프로젝트에서 클래스를 설계할 때 객체가 복사되어도 안전한지,
이동만 허용해야 하는지, 아니면 아예 복사를 막아야 하는지를 잘 판단해서 적용해야겠다.
'C++, CS' 카테고리의 다른 글
| [C++] map과 unordered_map 정리 (0) | 2026.04.30 |
|---|---|
| [C++] vector 와 list의 차이 정리 (0) | 2026.04.29 |
| [C++] 스마트 포인터 정리 (0) | 2026.04.27 |
| [C++] RTTI와 RAII 정리 (0) | 2026.04.24 |
| [C++] vtable 정리 (1) | 2026.04.23 |