[모던 C++ 디자인 패턴] 책을 바탕으로 공부하는 내용을 정리한 내용이다.
Singleton pattern
싱글턴 패턴은 가장 많이 미움받고 있는 디자인 패턴이다. 하지만, 필요하기 때문에 많이 사용된다.
싱글턴 디자인 패턴은 어떤 특정 컴포넌트의 인스턴스가 애플리케이션 전체에서 단 하나만 존재해야 하는 상황을 처리하기 위해 고안됐다.
개인적으로 처음 알았던 디자인 패턴이고, 그만큼 여기저기서 많이 사용되는 패턴이다. 그래서 왜 미움받는 디자인 패턴이라고 묘사했는지를 몰랐다. 책의 내용을 읽다 보니 그럴만한 이유들이 있구나 생각했다. 내가 사용하던 수준은 해당 문제들에 얽혀 있지 않은지 생각해볼 수 있었다.
IoC 컨테이너 방식으로의 전환도 만만치 않을 것이고 회사에 바로 적용한다고 해서 가능할지도 모르겠다. 종속성 주입이 문제다. 그래도 싱글턴이 자주 사용되는 만큼 한 번 고려해봐야겠다.
전역 객체로서의 싱글턴
가장 쉬운 싱글턴에 대한 접근 방법은 객체를 단 한번만 인스턴스화 하도록 약속하고 지키는 것이다. 하지만, 이 경우에는 약속을 지키지 않는 경우는 차치하더라도, 의도치 않게 생성자가 추가적으로 호출될 수도 있다. 복사 생성자/복사 대입 연산자/제어 역전 컨테이너 등 다양한 곳에 가능성이 도사리고 있다.
이 역시 간단하게 static 전역 객체를 하나 두면 쉽게 해결될 수 있다. 하지만, static 전역 객체도 문제가 있다. 각 컴파일 단위 바이너리에서 초기화 순서가 정의되어 있지 않아, 까다로운 문제를 일으킨다. 즉, 한 모듈에서 전역 객체를 참조해 수행을 할 때, 그 전역 객체가 참조하는 또 다른 전역 객체는 아직 초기화되지 않은 상태일 가능성이 있다.
전통적인 구현
위의 전역 객체를 사용하는 방식은 중요한 싱글턴의 제약조건을 빼놓고 있다. 객체가 추가로 생성되는 것을 막는 장치가 없는 것이다. static 전역 변수라고 할 지라도 인스턴스의 추가 생성을 막을 수는 없다. 만약 아래와 같이 카운터 변수를 두고 증가될 경우 예외가 발생하도록 하면 어떤가?
struct Database {
Database() {
static int instance_count(0);
if ( 1 < ++instance_count ) {
throw std::exception("Cannot make > 1 database!");
}
}
};
이 경우, 인스턴스가 여러 개 만들어지는 것에 대해서는 막을 수 있을 것이다. 하지만, 이는 사용자 관점에서는 생성에 실패하는 원인을 알기 어렵다. 사용자는 당연히 만들어질 것으로 예상하고 접근할 것이기 때문이다.
따라서, 아래의 전통적인 방식이 등장하게 된다. 생성자를 private으로 선언하고, 인스턴스를 반환받기 위한 멤버 함수를 만드는 것이다. 이 멤버 함수는 유일한 하나의 인스턴스만을 반환한다.
struct Database {
private:
Database() {
// something..
}
public:
static Database &get() {
// C++11 이후에만 쓰레드 세이프
static Database database;
return database;
}
Database(Database const &) = delete;
Database(Database &&) = delete;
Database &operator=(Database const &) = delete;
Database &operator=(Database &&) = delete;
};
인스턴스를 반환하는 멤버 변수를 제외하고 생성자를 숨길뿐만 아니라, 복사/이동 생성자/연산자를 삭제하기까지 했다. 하지만, 이와 같은 방식은 Database가 다른 static 또는 전역 객체에 종속적이고, 그런 객체를 소멸자에서 참조하고 있다면 위험할 수 있다. static 객체와 전역 객체의 소멸 순서는 결정되어 있지 않기 때문에, 참조하고 있는 다른 객체가 소멸이 먼저 됐다면 문제가 발생할 가능성이 있다.
멀티스레드 안전성
위의 코드에서 주석으로 추가했듯이 위의 싱글턴 초기화 방식은 C++11 이상부터만 스레드 세이프하다. 즉, C++11 전에는 두 개의 스레드가 동시에 get()을 호출하면 문제가 발생한다. 이를 위해 이중 검증 락킹(double-checked locking) 방법으로 생성자를 보호해야 한다.
struct Database {
public:
static Database &get() {
static Database *database = new Database();
return *database;
}
static Database &instance() {
Database *db = instance.load(boost::memory_order_consume);
if ( NULL == db ) {
boost::mutex::scoped_lock lock(mtx);
db = instance.load(boost::memory_order_consume);
if ( NULL == db ) {
db = new Database();
instance.store(db, boost::memory_order_release);
}
}
}
Database(Database const &) = delete;
Database(Database &&) = delete;
Database &operator=(Database const &) = delete;
Database &operator=(Database &&) = delete;
private:
Database() {
// something..
}
static boost::atomic<Database *> instance;
static boost::mutex mtx;
};
싱글턴의 문제
Database가 도시의 이름과 인구수의 목록을 담고 있다고 하면, 다음과 같이 도시의 이름에서 인구수를 얻는 인터페이스를 가질 수 있을 것이다. 그리고, Database 클래스를 상속받는 싱글턴 구현 클래스 SingletonDatabase가 있다고 해보자.
class Database {
public:
virtual int get_population(const std::string &name) = 0;
};
class SingletonDatabase : public Database {
public:
SingletonDatabase(SingletonDatabase const &) = delete;
void operator=(SingletonDatabase const &) = delete;
static SingletonDatabase &get() {
static SingletonDatabase db;
return db;
}
int get_population(const std::string &name) override {
return capitals[name];
}
private:
SingletonDatabase();
private:
std::map<std::string, int> capitals;
};
이제, 위의 문제가 발생할 수 있다고 했던 상황인, 다른 싱글턴 컴포넌트에서 또 다른 싱글턴을 사용할 때를 살펴보자. 여러 도시의 인구수의 합을 계산하는 싱글턴 컴포넌트 SingletonRecordFinder가 있다고 해보자.
class SingletonRecordFinder {
int total_population(std::vector<std::string> names) {
int result = 0;
for ( std::vector<std::string>::iterator itName = names.begin(); itName != names.end(); ++itName ) {
// SingletonDatabase와 밀접하게 의존
result += SingletonDatabase::get().get_population(*itName);
}
return result;
}
};
이 SingletonRecordFinder가 SingletonDataabase에 밀접하게 의존하고 있다는 사실이 문제가 발생하는 부분이다. 이럴 경우, 유연성이 약화된다.
예를 들어, SingletonRecordFinder의 단위 테스트 코드를 작성한다고 가정해보자. 아래의 코드와 같이 실제 데이터를 이용해 테스트를 해야 한다. 실제 데이터는 언제든 바뀔 수 있기 때문에 그때마다 테스트 코드를 수정하는 것은 낭비일 것이다. 그렇다고 더미 Database 컴포넌트를 사용할 수 없다는 것이 유연성을 확보할 수 없는 문제를 야기한다.
TEST(RecordFinderTests, SingletonTotalPopulationTest) {
SingletonREcordFinder rf;
std::vector<std::string> names{ "Seoul", "MexicoCity" };
int tp = rf.total_population(names);
EXPECT_EQ(17500000 + 17400000, tp); //< 실제 데이터로 비교해야 함
}
이를 해결하기 위한 방법 중 하나로 싱글턴 Database에 대한 명시적 의존을 제거하는 방법이 있다. 꼭 싱글턴 객체가 아니더라도 Database 인터페이스를 구현한 객체만 있으면 되게끔 만드는 것이다. 이를 위해 데이터를 어디서 얻을지 지정할 수 있는 ConfigurableRecordFinder를 만든다고 해보자.
struct ConfigurableRecordFinder {
// Database를 입력받아 지정
explicit ConfigurableRecordFinder(Database &db) : db(db) {
}
int total_population(std::vector<std::string> names) {
int result = 0;
for ( std::vector<std::string>::iterator itName = names.begin(); itName != names.end(); ++itName ) {
result += db.get_population(*itName);
}
return result;
}
Database &db;
};
이제는 위의 코드를 바탕으로 더미 데이터를 활용해 테스트를 수행할 수 있게 됐다.
class DummyDatabase : public Database {
public:
DummyDatabase() {
capitals["alpha"] = 1;
capitals["beta"] = 2;
capitals["gamma"] = 3;
}
int get_poplulation(const std::string &name) {
return capitals[name];
}
private:
std::map<std::string, int> capitals;
};
TEST(RecordFinderTests, DummyTotalPopulationTest) {
DummyDatabase db();
ConfigurableRecordFinder rf(db);
// 테스트용 더미 테스트로 이후 테스트 코드 수정이 필요 없음
EXPECT_EQ(4, rf.total_population(std::vector<std::string>("alpha", "gamma")));
}
싱글턴과 제어 역전(Inversion of Control)
어떤 컴포넌트를 명시적으로 싱글턴으로 만드는 것은 과도하게 깊은 종속성을 유발한다. 따라서 싱글턴 클래스를 다시 일반 클래스로 만들 때도 수정 비용이 많이 든다. 클래스의 생성 소멸 시점을 직접적으로 강제하는 대신 IoC 컨테이너에 간접적으로 위임하는 방법이 있다.
종속성 주입 프레임워크인 Boost.DI를 이용하면, IoC 관례에 맞춰 아래와 같이 작성이 가능하다.
auto injector = di::make_injector(
di::bind<IFoo>.to<Foo>.in(di::singleton),
// 기타 작업
);
위의 코드의 의미는, IFoo(Foo 인터페이스) 타입 변수를 멤버로 가지는 컴포넌트가 생성될 때마다 IFoo 타입 멤버 변수를 Foo의 싱글턴 인스턴스로 초기화한다는 것을 의미한다.
이렇게 종속성 주입 컨테이너를 활용하는 방식이 바람직한 싱글턴 패턴의 구현 방법이라고 한다. 이렇게 구현할 때야 싱글턴 객체를 뭔가 다른 것으로 바꿔야 할 때도 코드 한 군데(컨테이너 설정 코드)만 수정하면 된다.
추가적인 장점으로, 싱글턴을 직접 구현할 필요 없이, Boost.DI 프레임워크에서 자동으로 처리해줘 싱글턴 로직을 잘못 구현할 오류의 여지를 없애준다는 점이 있다. 게다가, Boost.DI는 스레드 세이프하다.
모노스테이트(Monostate)
모노스테이트는 싱글턴 패턴의 변형이다. 일반 클래스와 같아 보이지만, 동작은 싱글턴처럼 한다.
class Printer {
public:
int get_id() const {
return id;
}
void set_id(int value) {
id = value;
}
private:
static int id;
};
일반 클래스와 같아 보이지만, 멤버 변수를 static 데이터를 사용하고 있다. 사용자는 일반 클래스인 것으로 알고 Printer 인스턴스를 만들지만, 실제로는 모든 인스턴스가 같은 값을 보고 있는 것이다.
모노스테이트 방식은 어느 수준에서는 잘 동작하고 몇 가지 장점도 있다. 무엇보다 상속받기 쉬워 다형성을 활용할 수 있다. 그리고, 경우에 따라 생명 주기도 적절히 잘 정의된다. 가장 큰 장점이라고 한다면 시스템에서 사용 중인 이미 존재하는 객체를 이용할 수 있다는 점이다. 기존 시스템의 대규모 구조 변경 없이 기존 코드를 조금 수정함으로써 기존 기능에 변화를 일으키지 않고서도 싱글턴 특성을 추가할 수 있다.
반면, 모노스테이트 방식의 단점으로는 코드 깊숙이 손을 대야 한다는 것이다. 일반 객체를 모노스테이트로 변환하는 것은 만만치 않은 일이다. static 멤버를 사용하기 때문에 실제 객체가 인스턴스화 되는 것과 관계없이 항상 메모리를 차지하는 것도 문제다. 가장 큰 단점은 클래스의 필드가 항상 get/set 멤버 함수를 통해 접근되는 것으로 가정했기 때문에 직접 노출된 멤버 변수가 있다면 리팩터링 작업이 만만치 않다는 것이다.
요약
신중하게 사용하면 싱글턴 패턴 자체는 나쁘지 않다. 그러나, 일반적으로는 테스트와 리팩터링 용이성을 헤칠 수 있다. 따라서 싱글턴을 사용해야 한다면, 직접적인 호출 방식(getInstance() 식의 호출)을 피해야 한다.
종속성 주입 방식(생성자 인자 등)으로 모든 종속성이 전체 코드의 한 곳에 관리될 수 있는 형태로 활용하는 것이 바람직하다.
'study > design pattern' 카테고리의 다른 글
[디자인패턴][구조패턴] 브릿지 Bridge - C++ (0) | 2022.08.07 |
---|---|
[디자인패턴][구조패턴] 어댑터 Adapter - C++ (0) | 2022.05.11 |
[디자인패턴][생성패턴] 프로토타입 Prototype - C++ (0) | 2022.04.28 |
[디자인패턴][생성패턴] 팩터리 Factory - C++ (0) | 2022.03.12 |
[디자인패턴][생성패턴] 빌더 Builder - C++ (0) | 2022.03.09 |