[디자인패턴 with C++] Singleton Pattern

Jin Hur·2022년 5월 30일
0
post-custom-banner

reference: "모던 C++ 디자인패턴" / 디미트리 네스터룩

메모리에 DB를 로드하고 읽기 전용 인터페이스를 제공하는 경우 싱글톤 패턴을 활용하기 딱 좋은 상황이다. 동일한 데이터를 여러 번 로드하여 메모리를 낭비할 필요가 없기 때문이다.

전역 객체로서의 싱글톤

class Database {
    /*
    * 이 객체를 두 개 이상 인스턴스화하지 마시오.
    */
    
public:
    Database() {}
};

위와 같이 단순히 객체를 하나만 생성하라는 주석으로 약속하는 것에는 한계가 있다. 다른 개발자가 이 약속을 무시하는 것 외에도, 의도하지 형태로 생성자가 호출될 수 있기 때문이다. 복제 생성자, 복제 대입 연산자에 의한 것을 수도 있고, make_unique()의 호출, 또는 제어 역전(IoC) 컨테이너의 사용에 따른 것을 수도 있다.

이를 극복할 수 있는 첫 번째 아이디어는 static 전역 객체를 두는 것이다.

static Database db();

하지만 전역 객체로 두는 방식에도 문제가 있다. 각각의 컴파일 바이너드들에서 초기화 순서가 정의되어 있지 않다는 점이다. static 전역 객체가 여러 개가 사용되고, 어느 한 모듈에서 전역 객체를 참조할 때 그 전역 객체가 참조하는 또 다른 전역 객체가 아직 초기화된 상태가 아닐 수 있다는 점이다.
나아가 다른 사용자(개발자)가 이런 전역 객체가 있다는 사실을 알 지 못할 수도 있다. IDE에서 자동완성 기능으로 존재를 알 수도 있지만 이는 자연스러운 방법이라 말하기 어렵다.

이러한 전역 객체 방식의 문제를 극복하기 위해 사용자가 더 직관적으로 접근할 수 있고, 필요한 객체를 반환받을 수 있는 전역 함수를 제공할 수 있다.

Database& get_database() {
    static Database db;
    return db;
}

위 함수를 호출하면 DB에 접근할 수 있는 참조를 얻을 수 있다. 하지만 이러한 방식은 쓰레드 안전성이 보장되어야 한다. static 객체를 초기화하는 코드 앞뒤로 락이 삽입되어 초기화 와중에 동시에 다른 쓰레드에서 접근하는 것을 방지해 주는지 확인해야 한다.


전통적인 싱글톤 구현 방식

Database& get_database() {
	static Database db;
    return db;
}

위 구현은 근본적인 것을 빠뜨리고 있다. 바로 객체가 추가로 생성되는 것을 막을 장치가 없다는 것이다. static 전역 변수로 관리한다고 해서 인스턴스 추가 생성을 막을 수 없다.

이에 쉬운 대책을 추가할 수 있다. 바로 생성자 안에 static 카운터 변수를 두고 값이 증가될 경우 예외를 발생시키는 것이다.

class Database {
public: 
    Database() {
        static int instance_count{0};
        if(++instance_count > 1)
            throw exception("Cannot make > 1 database!");
    }
};

위와 같은 방식으로 인스턴스가 한 개보다 많이 만들어지지는 않을 것이다. 하지만 사용자(다른 개발자)와의 소통 측면에서는 좋지 않다. 사용자 관점에서 인터페이스상으로는 생성자가 단 한 번만 호출되어야 한다는 것을 알 수 없기 때문이다.

DB 객체를 사용자가 명시적으로 생성하는 것을 막는 방법은 생성자 자체에 접근하지 못하도록 private으로 선언하고 인스턴스를 리턴받기 위한 멤버 함수를 만드는 것이다.

class Database {
protected:
	Database() {
		// ...
	}

	int data = 0;

public:
	static Database& get() {
		// c++ 11 이상 버전에서는 쓰레드 세이프 함.
		static Database db;
		cout << "DB 객체 생성!" << endl;
		return db;
	}

	// 복사 생성자 삭제
	Database(Database const&) = delete;
	// 이동 생성자 삭제
	Database(Database&&) = delete;
	// 복사 연산자 삭제
	Database& operator=(Database const&) = delete;
	// 이동 연산자 삭제
	Database& operator=(Database&&) = delete;

	int getDataValue() {
		return data;
	}
	void increaseDataValue() {
		data++;
	}
};

int main() {
	Database& db  = Database::get();
	db.increaseDataValue();
	cout << db.getDataValue() << endl;

	Database& db2 = Database::get();
	cout << db2.getDataValue() << endl;
}

아래 출력 결과물을 통해 유일한 DB 객체만이 생성되었음을 확인할 수 있다.

그리고 추가적인 요령으로 get() 함수에서 힙 메모리 할당으로 객체를 생성하게 한다. 이렇게 함으로써 전체 객체가 아닌 포인터만 static으로 존재할 수 있다.

static Database& get() {
    static Database* dbPtr = new Database();
    return (*dbPtr);
}

위 코드는 메모리 낭비를 일으키지 않는다. 사용자의 get() 요청시에만 메모리가 할당되기 때문이다. 또한 메모리 누수도 발생하지 않는데, 그 이유는 static 변수의 초기화는 전체 런타임 중 단 한 번만 수행되기 때문이다.

멀티쓰레드에서의 안전성

위 코드와 같은 경우 앞서 언급하였듯 C++11 버전 이상부터 쓰레드 세이프하다. 즉, 두 개의 쓰레드가 동시에 get()을 호출하더라도 DB가 두 번 생성되는 일이 없다는 것이다.

C++11 이전 버전에서는 개발자가 직법 DCL(Double-Checked Locking) 기법 등을 적용하여 생성자를 보호해야 한다.


싱글톤과 제어 역전(Inversion of Control)

싱글톤을 직접 구현할 필요 없이 의존성 주입 Boost.DI 프레임워크를 통해 싱글톤 패턴을 적용할 수 있다.

클래스의 생성 소멸 시점을 직접적으로 강제하는 대신 IoC 컨테이너에 간접적으로 위임하는 방식을 사용하면서, 싱글톤 컴포넌트를 IoC 관례에 맞추어 정의할 수 있다.

auto injector = di::make_injector(di::bind<IFoo>.to<Foo>,in(di::singleton)
								// , 기타 설정 작업들.. );

Boost.DI 의존성 주입 예제 포스팅: https://devjino.tistory.com/12

위 코드에서는 타입 이름에 앞첨자 I를 붙여 인터페이스 목적의 타입임을 나타내고 있다. 여기서 bi::bind 코드가 의미하는 바는, IFoo 타입 변수를 멤버로 가지는 컴포넌트가 생성될 때마다 IFoo 타입 멤버 변수를 Foo의 싱글톤 인스턴스로 초기화한다는 것을 뜻한다.

많은 개발자들에 따르면 이렇게 의존성 주입 컨테이너를 활용하는 방식만이 바람직한 싱글턴 패턴 구현 방법이라 한다.
의존성 주입 방식으로 생성에 대한 책임과 비즈니스 로직에 대한 책임을 분리시키고, 싱글톤을 직접 구현하지 않아 싱글톤 로직을 잘못 구현하는 오류의 여지도 없애준다. 나아가 Boost.DI는 쓰레드 세이프하다.

post-custom-banner

0개의 댓글