객체생성패턴 #1 - std::shared_ptr

dusunim·2023년 2월 11일
0

안전함이 '보증'된 포인터

1. 포인터 주소에 안전하게 액세스하기

포인터가 가리키는 주소 값에 0을 사용하는 것이 특별한 의미를 갖게 된 이유를 찾아 올라가다보면, 아주아주 오래 전 0번 부터 시작하는 address가 하드웨어(특히 ROM)를 위해 예약된 공간이었거나, address 0(이하 null 또는 nullptr)이 interrupt handler를 가리키는 주소였다는 것 등을 발견할 수 있다. 아래의 C 표준은 이러한 역사적(?)인 배경에서 유래되었다고 할 수 있을 것이다.

  • malloc(size)은 더 이상 memory를 allocation 할 수 없을 때 0을 반환한다.
    (0은 유효한 주소 공간이 될 수 없다. 따라서 access 가능하지 않다.)
  • free(0) 은 아무 동작도 수행하지 않는다. (null 포인터를 할당 해제하는 것은 안전하다).

💡 C++ 표준은 조금 다르지만... 이 디테일이 크게 중요한 건 아니다.

  • new 의 경우 out of memory(OOM) 상황에서 std::bad_alloc 예외가 발생한다. malloc(size)와 마찬가지로 nullptr을 반환받기 위해서는 std::nothrow 태그를 지정해야함 함.
  • delete의 경우는 nullptr에 대해 실행되어도 문제가 없지만 사용자 정의 deallocation 함수가 지정될 경우 달라질 수는 있다.

어쨌거나 C/C++ 프로그래밍 세계에서, null 값을 갖는 포인터 변수는 자연스레 uninitialized pointer 를 의미하게 되었고, 클래스가 다른 클래스를 멤버로 aggregation 하는 경우 생성자에서 포인터 멤버변수를 nullptr로 초기화 하는 것은 대게의 개발 조직에서 필수적으로 권고하는 사항이 되어 왔다.

그러나 uninitialized pointer 임을 지정하기 위해 nullptr 을 사용한다는 것이 곧 null이 아닌 주소를 참조해도 안전하다는 것을 뜻하지는 않는다. free, delete등에 의해 무효화(invalidation)되면 더 이상 안전하지 않게 되므로. 후술한 Guarantee 1을 참으로 만들고자 등장한, 객체의 invalidation 여부를 보다 무결하게 판단하려던 기법이 reference counted (smart) pointer 였고, std::shared_ptr은 이에 대한 다양한 구현 중 하나이다. 흔히들 shared_ptr를 사용하는 이유로 참조 카운팅 방식의 라이프타임 관리 방식 그 자체를 말하곤 한다. 아래처럼...,

하지만 개인적으로 std::shared_ptr 이 제공하는 가장 중요한 가치는 아래의 전제를 '보증'하는 것이라 생각한다.

Gurantee 1 null이 아닌 shared_ptr을 사용하는 것은 언제나 안전하다.

이는 곧 "null이 아니면서 invalidated인 shared_ptr 객체는 존재하지 않는 상태"를 의미한다.

가볍지 않은 의미를 갖고 있지만 안타깝게도 몇 가지 이유로 쉽게 실현되지 않는다. 이 글에서는 Guarantee 1 의 실현을 돕는 몇 가지 제안을 통해, 애플리케이션 개발 시 "개발자가 객체 포인터의 null 체크만 잘 하면 access violation에서 해방"될 수 있는 기반을 얘기해볼까 한다.

2. Should pass a shared_ptr by 'value'

(이 글은 엄청난 vote 수를 가진 아래 Stack Overflow 답변과는 완전히 반대되는 입장에서 작성되었다)

2.1 취약성의 창 (Windows of Vulnerability)

근래의 C++에서 원시 포인터(raw pointer)는 비관리형 자료형(unmanaged type)으로 간주되며, 이를 사용하는 동안은 Guranatee 1이 성립될 수 없다. 흔히 알려진 원시 포인터를 사용할 때의 사례를 들어보자면,

원시 포인터로부터 shared_ptr 객체를 만들기

아래처럼 (원시 포인터를 반환하는) 객체의 생성 시점과 shared_ptr 객체를 초기화하는 시점 간 span이 멀어질 경우 문제가 될 수 있다. 모두 동일한 스코프에서 이루어진다면 발생하는 문제는 개발자의 단순 실수로 간주될 수도 있겠지만, raw_ptr이 바깥쪽 스코프에서 훨씬 더 긴 lifetime을 갖게 된다면 취약성의 창(window of vulnerability)이 얼나나 크게 열릴지는 예측하기 어렵다.
다소 억지스럽다고 생각될 수도 있겠지만, 실제 발생하는 문제가 되는 사례들을 짧게 축약하면 결국 요런 꼴로 표현된다고 이해해주기 바란다.

void a_function()
{
	Type* raw_ptr = new Type();
	std::shared_ptr<Type> ptr1(raw_ptr);

	// after hundreds of lines...
	std::shared_ptr<Type> ptr2(raw_ptr);
} // <== program crashes when ptr1 is destroyed!
 
void another_function()
{
	Type* raw_ptr = new Type();
	std::shared_ptr<Type> ptr1(raw_ptr);
 
	// after hundreds of lines...
	ptr1.reset(raw_ptr);
} // <== program crashes when ptr1 is destroyed!

shared_ptr 객체로부터 원시 포인터 변수를 만들기

null이 아닌 shared_ptr을 사용하는 것이 항상 안전하다 하더라도 shared_ptr::get()으로부터 얻어낸 raw pointer를 access하는 게 늘 안전하다는 의미는 아니다. 특히 다중 쓰레드 환경에서 실행 경로를 예측하기 어려운 경우.

void a_function()
{
	auto vec_ptr = std::make_shared<std::vector<int>>);
 
	auto raw_ptr = vec_ptr.get();
 
	std::thread([raw_ptr] {
		raw_ptr->push_back(1); // <= program crashes here!
	}).detach();
}

이런 일은 별로 발생하지 않을 것 같은가? 오늘날 동작하는 (특히 데스크탑) 소프트웨어들은 실행과 동시에 적어도 십수 개의 쓰레드를 생성하는 게 보통이다.

shared_ptr<T>operator T*() const를 오버로딩하지 않아 아래의 코드가 컴파일조차 되지 않는다. shared_ptr::get()을 명시적으로 사용하할 수 밖에 없도록, 굳이 불편하게 만든 것에는 다 그럴 만한 이유가 있다.

void an_async_function(std::vector<int>* raw_ptr)
{
	std::thread([raw_ptr] {
		raw_ptr->push_back(1);
	}).detach();
}
 
void a_function()
{
	auto vec_ptr = std::make_shared<std::vector<int>>();
 
	an_async_function(vec_ptr); // <= compilation error here!
}

shared_ptr 객체의 주소 또는 참조를 사용하기

shared_ptr::get() 뿐 아니라, shared_ptr 객체를 가리키는 포인터를 사용하는 것 역시도 문제가 된다.

void an_unsafe_function(std::shared_ptr<std::vector<int>>* raw_ptr)
{
	(*raw_ptr)->push_back(1);
}
 
void a_function()
{
	auto vec_ptr = std::make_shared<std::vector<int>>();
 
	std::thread([&vec_ptr] {
		an_unsafe_function(&vec_ptr); // <= program crashes!
	}).detach();
}

동일한 이유에서 shared_ptr 객체의 reference 를 사용하는 것 역시 안전하지 않다.

void an_unsafe_function(std::shared_ptr<std::vector<int>>& ref_ptr)
{
	ref_ptr->push_back(1);
}
 
void a_function()
{
	auto vec_ptr = std::make_shared<std::vector<int>>();
 
	std::thread([&vec_ptr] {
		an_unsafe_function(vec_ptr); // <= program crashes!
	}).detach();
}

shared_ptr 객체의 멤버 변수에 접근하기

아래와 같은 형태로 shared_ptr 객체의 멤버 변수에 access 하는 getter/setter 하나하나도 결과적으로는 취약성의 창을 열어줄 뿐이다.

class ATypicalClass
{
public:
	ATypicalClass() : value_(std::make_shared<MyType>()) {}
 
	MyType* unsafe_value_ptr() { return value_.get(); }
 
	MyType& unsafe_value_ref() { return *value_; }
 
	std::shared_ptr<MyType>* unsafe_ptr() { return &value_; }
 
	std::shared_ptr<MyType>& unsafe_ref() { return value_; }
 
private:
	std::shared_ptr<MyType> value_;
};

오직 아래 방식으로만 접근해야 한다는 뜻이다.

class ATypicalClass
{
public:
	ATypicalClass() : value_(std::make_shared<MyType>()) {}
 
	std::shared_ptr<MyType> safe_ptr() { return value_; }
 
private:
	std::shared_ptr<MyType> value_;
};

2.2 Pratices and Patterns

앞서 나열한 점들을 고려해서 Gurantee 1이 성립되기 위해 실천해야 할 내용을 명시하면 아래와 같다.

Practice 1 shared_ptr 객체를 만드는 과정에서 명시적으로 new를 사용하지 않는다.

Practice 2 일단 만들어진 shared_ptr는 raw_pointer 또는 reference 로 변환하지 않는다.

Practice 1 을 위한 보다 구체적인 방안은 아래 정도가 있을 것이다. 널리 알려진 것들이기도 하고...

  • (기본 자료형의) 배열 대신 std::vector<> 등의 컨테이너 클래스 사용.
  • 객체를 직접 생성하는 대신 std::make_shared<> 사용

하지만 기본적으로 개발자의 선의에 의존하는 방식이기 때문이라는 한계가 있다. "내가 만든 객체는 꼭 std::make_shared를 사용해서 생성해주세요..." 라고 강제할 수는 없는 것 아닌가? 게다가 원시 포인터와 shared_ptr이 혼용되는 코드가 한두 곳에 만들어진다면 둘 모두를 다루는 코드(함수)를 구현하기 위해 raw pointer를 인자로 받는 함수를 만들 수 밖에 없다. 마치 const나 volatile 같은 한정자(qualifier)가 전파되는 것 처럼 raw pointer를 사용하는 코드들이 늘어나게 되고, 이는 Practice 2를 어렵게 하는 악순환을 이룬다. 그러니 Practice 1을 조금 강하게 바꿔 보겠다.

Practice 1’ 모든 객체는 shared_ptr로만 생성될 수 있도록 한다.

Practice 2 일단 만들어진 shared_ptr는 raw_pointer 또는 reference 로 변환하지 않는다.

Practice 1' 은 아래처럼 private 생성자와 public static 생성함수로 구성된 구현 패턴에 의해 가능해진다. 객체의 생성 방식을 강제하므로 넓은 의미에서 factory method pattern이라고도 볼 수 있을 것이고...

class ATypicalClass
{
private:
	ATypicalClass() = default;
 
public:
	static auto create() {
		return std::shared_ptr<ATypicalClass>(new ATypicalClass);
	}
};
 
void the_only_way_to_create_an_object()
{
	auto obj = ATypicalClass::create();
}

💡 shared_ptr 의 오버헤드

Thread safety를 보장하기 위해 shared_ptr에서 (복사 등에 의해) reference count가 변경될 때에는 atomic operation 이 수행되므로 reference 또는 pointer를 사용하는 것 보다 시간이 더 소요된다.(시스템에 따라 다르지만 대략 초당 0.5 ~ 1억 개의 shared_ptr 객체가 생성+소멸될 수 있다).

이렇게 증가되는 수행시간이 프로그램의 성능을 저하시킬 정도로 문제가 된다면 Practice 2 를 지키기 어려울 것이다. 성능저하 비용이 크다면 당연히 reference 또는 pointer 사용을 고려할 수 있고, 이게 앞서 인용한 https://stackoverflow.com/a/8741626 의 내용이기도 하다. 하지만 Practice 2를 포기하기에 앞서 설계를 먼저 검토해보기를 권한다. (이렇게 shared_ptr 객체가 빈번하게 전달되어야만 하는 이유가 과연 무엇인지, 또 회피할 수 없는지를 검토하자는 뜻이다...)

이 구현패턴을 객체 팩토리 패턴으로 변형하면서 실제 프레임워크에 사용할 수 있을 정도로 확장해보려 한다. (내용이 길어져 아래 정도의 목차와 내용으로 다음 글에...)

3. 확장에 대해 열고 수정에 대해 닫기

Make it open for extension, but closed for modification. 객체지향 설계 원칙 중 OCP(Open/Closed Principle) 적용하기...

4. 객체 생성 패턴이 필요한 이유

모든 것의 어머니 - IObject의 등장과 파생 클래스 생성 패턴...

5. 프로그램 로딩 시점에 등록하기

프로그램 실행과 동시에 객체 생성기 등록하기...

profile
C++로 기하 알고리즘과 애플리케이션 아키텍처 위주로 작업해온 시니어 개발자였습니다만, 요즘은 React.js, Node.js, Kubernetes 등등... 프론트와 백엔드 이것저것을 배워가는 중입니다.

0개의 댓글