생성 패턴 - ⑤ 싱글턴 패턴
객체를 생성하는 일은 다소 번거롭고 많은 주의가 요구됩니다. 만약 생성 절차가 특별한 규칙을 따라야 한다면 객체를 생성하는 일은 더욱 까다로워집니다. 디자인 패턴에서 생성 패턴은 객체 생성의 어려움을 해소하기 위해 등장했습니다. 생성 패턴은 객체를 만드는 복잡한 과정을 추상화한 패턴입니다. 생성 패턴의 종류는 다음과 같습니다.
- Factory Pattern
- Factory Method Pattern
- Abstract Factory Pattern
- Builder Pattern
- Singleton Pattern
- Prototype Pattern
이번 글에서는 다섯 번째 싱글턴 패턴에 대해서 다뤄보도록 하겠습니다.
Singleton Pattern
싱글턴은 디자인 패턴 역사상 가장 많은 미움을 받고 있는 디자인 패턴입니다. 하지만 싱글턴을 사용했을 때 효과적으로 해결할 수 있는 문제가 있기 때문에 지금까지도 사용되고 있습니다. 우선 싱글턴의 장단점을 살펴보기 전에 먼저 싱글턴에 대해서 알아보도록 하겠습니다.
싱글턴의 필요성
싱글턴 패턴은 어떤 컴포넌트의 인스턴스가 애플리케이션 전체에서 단 하나만 존재해야 하는 상황을 처리하기 위해 고안되었습니다. 예를 들어 메모리에 데이터베이스를 로딩하고 데이터베이스에 접근하는 인터페이스를 제공하는 경우 싱글턴을 활용하기 좋은 상황입니다. 데이터베이스를 메모리에 두 번 로딩하면 시간과 공간의 낭비이며, 애플리케이션에 동일한 데이터베이스가 중복으로 생성된다면 이상 동작을 할 가능성이 크기 때문입니다.
데이터 베이스를 오직 하나만 생성하기 위해서 다음과 같이 주석으로 명시할 수 있을 것입니다.
class Database
{
public:
// 이 객체를 두 개이상 인스턴스화하지 마시오.
Database() {}
};
하지만 이 방법은 추가적인 객체 생성을 강제로 막을 수 없다는 문제가 있습니다. 다른 개발자가 주석을 확인하지 못하고 객체를 생성할 수도 있고, 복제 생성자, 복제 대입 연산자, make_unique()의 호출 등에서 음밀하게 새로운 객체가 생성될 수 있습니다.
전통적인 싱글턴 구현
추가적인 객체 생성을 막기 위해서는 모든 생성자를 private 으로 선언하고 사용자가 유일한 싱글턴 객체를 획득할 수 있는 인터페이스를 제공해야 합니다. 이를 위해서 다음과 같이 구현할 수 있습니다.
class Database
{
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;
private:
Database() {}
};
int main()
{
Database& db = Database::Get();
return 0;
}
만약 Database의 객체가 클 경우 힙 메모리 할당으로 객체를 생성할 수 있습니다. 이렇게 함으로써 전체 객체가 아니라 포인터만 static으로 존재할 수 있습니다.
static Database* Get()
{
static Database* database = new Database();
return database;
}
하지만 포인터를 활용할 경우 사용자가 실수로 객체를 delete를 해버릴 경우 싱글톤이 파괴되는 문제가 생길 수 있습니다. 가급적 참조 반환을 사용하는 것이 더 안전합니다.
만약 C++11 이전 버전이라면 멀티스레드 환경에서 객체가 이중으로 생성될 문제가 있습니다. 이럴 때는 이중 검증 락킹 방법으로 생성자를 보호해야 합니다.(C++11 이상부터는 컴파일러에서 스레드 세이프를 보장하고, 최근에는 대부분 C++11 이상 버전을 사용하기 때문에 예제 코드는 생략합니다.)
싱글턴의 문제
싱글턴의 문제는 싱글턴에 종속된 클래스의 단위 테스트가 어렵다는 점입니다. 이는 싱글턴은 오직 하나의 객체만 생성하기 때문에 같은 클래스로 만들어진 더미 객체를 생성할 수 없기 때문입니다. 예시를 통해서 살펴봅시다.
데이터베이스 싱글턴 객체를 사용하여 여러 도시의 인구수 합을 계산하는 RecordFinder클래스가 있다고 가정해봅시다. 먼저 데이터 베이스 싱글턴 객체는 다음과 같습니다.
class Database
{
public:
virtual int GetPopulation(const string& name) = 0;
};
class SingletonDatabase : public Database
{
public:
static SingletonDatabase& Get()
{
static SingletonDatabase db;
return db;
}
int GetPopulation(const string& name) { return mCapitals[name]; }
SingletonDatabase(SingletonDatabase const&) = delete;
SingletonDatabase& operator=(SingletonDatabase const&) = delete;
private:
SingletonDatabase() {/*데이터 베이스에서 데이터 읽어 들이기*/ }
map<string, int> mCapitals;
};
데이터 베이스 싱글턴 객체를 사용해서 여러 도시들의 인구수 합을 계산하는 RecordFinder는 다음과 같이 구현할 수 있습니다.
class RecordFinder
{
public:
int total_population(vector<string> names)
{
int result = 0;
for (auto& name : names)
{
result += SingletonDatabase::Get().GetPopulation(name);
}
return result;
}
};
int main()
{
RecordFinder rf;
rf.total_population({ "Seoul", "Mexico City" });
return 0;
}
여기서 RecordFinder에 대한 단위 테스트를 한다고 생각해봅시다. RecordFinder는 SingletonDatabase에 종속적이기 때문에 SingletonDatabase 에 존재하는 실제 데이터로만 테스트를 해야 합니다.
RecordFinder rf;
int tp = rf.total_population({ "Seoul", "Mexico City" });
EXPECT_EQ(17'500'000 + 17'400'000, tp);
하지만 실제 데이터는 언제든 변할 수 있습니다. 그래서 실제 데이터가 변할 때마다 테스트 코드를 수정해야 하는 문제가 발생합니다. 더미 데이터로 RecordFinder를 테스트하면 좋지만 싱글턴에 종속된 설계에서 이런 접근은 쉽지 않습니다. 이런 유연성 부족이 싱글턴의 단점입니다.
싱글턴에 대한 명시적 종속성 제거
이번 문제를 해결하기 위해서는 싱글턴에 대한 명시적 종속성을 제거해야 합니다. RecordFinder는 Database에서 제공해주는 GetPopulation함수만 있으면 동작할 수 있기 때문에 SingletonDatabase이 아닌 추상 클래스 Database에 의존하도록 수정하여 문제를 해결할 수 있습니다.
class DummyDatabase : public Database
{
public:
DummyDatabase() // 더미 데이터 생성
{
mCapitals["alpha"] = 1;
mCapitals["beta"] = 2;
mCapitals["gamma"] = 3;
}
int GetPopulation(const string& name) { return mCapitals[name]; }
private:
map<string, int> mCapitals;
};
class RecordFinder
{
public:
explicit RecordFinder(Database& db)
: mDb(db) {}
int total_population(vector<string> names)
{
int result = 0;
for (auto& name : names)
{
result += mDb.GetPopulation(name);
}
return result;
}
private:
Database& mDb;
};
int main()
{
TEST(RecordFinderTests, DummyTotalPopulationTest)
{
DummyDatabase dp{};
RecordFinder rf{ dp };
int tp = rf.total_population({ "alpha", "beta" });
EXPECT_EQ(1 + 2, tp);
}
return 0;
}
정리
- 신중하게만 사용한다면 싱글턴 패턴 자체가 나쁜 것은 아니다.
- 싱글턴 패턴은 테스트와 리팩터링 용이성을 헤칠 수 있다.
- 꼭 사용해야 한다면 직접적인 종속성을 가지는 것을 피하는 것이 좋다.
- 멀티스레드 환경에서 단일 객체 생성이 보장되지 않기 때문에 사용 시 유의해야 한다.(C++11 이상부터는 안전함)
<출처>
유튜브 채널 "코드없는 프로그래밍": https://www.youtube.com/channel/UCHcG02L6TSS-StkSbqVy6Fg
길벗 출판사: 모던 C++ 디자인 패턴(Dmitri Nesteruk 저)