기록공간

5장. Singleton Pattern (싱글턴 패턴) 본문

Game Design Pattern, Logic

5장. Singleton Pattern (싱글턴 패턴)

입코딩 2020. 2. 14. 18:02
반응형

"오직 한 개의 클래스 인스턴스만을 갖도록 보장하고, 이에 대한 전역적인 접근점을 제공합니다." (GoF의 디자인 패턴 181p)


이번 장은 어떻게 하면 패턴을 쓸 수 있는지를 보여준다는 점에서 다른 장과는 정반대다. 싱글턴 패턴은 의도와는 달리 득보다는 실이 많다. GoF도 싱글턴 패턴을 남용하지 말라고 강조한다.

 

워낙 남용되는 패턴이다 보니 이 장에서는 싱글턴을 피할 방법을 주로 다루겠지만, 그래도 우선은 싱글턴 패턴에 대해서 살펴보자.


싱글턴 패턴

GoF의 디자인 패턴에서 발췌한 싱글턴 요약을 쉼표 기준으로 나눠 각각 살펴보자.

 

 

오직 한 개의 클래스 인스턴스만 갖도록 보장

인스턴스가 여러 개면 제대로 작동하지 않는 상황이 종종 있다. 외부 시스템과 상호작용하면서 전역 상태를 관리하는 클래스 같은게 그렇다.

 

예를들어 파일 시스템 API를 래핑하는 클래스가 있다고 해보자. 파일 작업은 완료하는 데 시간이 좀 걸리기 때문에 이 클래스는 비동기로 동작하게 만들어야 한다. 즉 여러 작업이 동시에 진행될 수 있으므로 작업들을 서로 조율해야 한다. 한쪽에서는 파일을 생성하고 다른 한쪽에서는 방금 생성한 파일을 삭제하려고 한다면, 래퍼 클래스가 두 작업을 다 파악해서 서로 간섭하지 못하게 해야한다.

 

이를 위해서는 파일 시스템 클래스로 들어온 호출이 이전 작업 전체에 대해서 접근할 수 있어야 한다. 아무 데서나 파일 시스템 클래스 인스턴스를 만들 수 있다면 다른 인스턴스에서 어떤 작업을 진행 중인지를 알 수 없다. 이를 싱글턴으로 만들면 클래스가 인스턴스를 하나만 가지도록 컴파일 단계에서 강제할 수 있다.

 

 

전역 접근점을 제공

콘텐츠 로딩, 게임 저장 등 여러 내부 시스템에서 파일 시스템 래퍼 클래스를 사용할 것이다. 이들 시스템에서 파일 시스템 클래스 인스턴스를 따로 생성할 수 없다면, 파일 시스템에는 어떻게 접근해야 할까?

 

싱글턴 패턴은 여기에 대한 해결책도 제공한다. 하나의 인스턴스만 생성하는 것에 더해서, 싱글턴은 그 인스턴스를 전역에서 접근할 수 있는 메서드를 제공한다. 이를 통해, 누구든지 어디서든지 인스턴스에 접근할 수 있다. 이 모든 걸 제공하는 클래스는 다음과 같이 구현할 수 있다.

class FileSystem
{
public:
	static FileSystem& instance()
	{
		if (nullptr == instance_)
			instance_ = new FileSystem();

		return *instance_;
	}
private:
	FileSystem() {}
	static FileSystem* instance_;
};

 

instance_ 정적 멤버 변수는 클래스 인스턴스를 저장한다. 생성자가 private이기 때문에 밖에서는 생성할 수 없다. public에 있는 instance() 정적 메서드는 코드 어디에서나 싱글턴 인스턴스에 접근할 수 있게 하고, 싱글턴을 필요로 할 때까지 인스턴스 초기화를 미루는 역할(게으른 초기화)도 한다.

 

다음과 같이 만들기도 한다.

class FileSystem
{
public:
	static FileSystem& instance()
	{
		static FileSystem* instance = new FileSystem();
		return *instance;
	}
private:
	FileSystem() {}
};

싱글턴을 왜 사용하는가?

 

한 번도 사용하지 않는다면 아예 인스턴스를 생성하지 않는다

메모리와 CPU 사용량을 줄이는 건 언제든지 환영이다. 싱글턴은 처음 사용될 때 초기화 되므로, 게임 내에서 전혀 사용되지 않는다면 아예 초기화되지 않는다.

 

 

런타임에 초기화된다

보통 싱글턴 대안으로 정적 멤버 변수를 많이 사용한다. 하지만 정적 멤버 변수는 자동 초기화(Automatic initialization)되는 문제가 있다. 즉, 컴파일러는 main 함수를 호출하기 전에 정적 변수를 초기화하기 때문에 프로그램이 실행된 다음에야 알 수 있는 정보를 활용할 수 없다. 정적 변수 초기화 순서도 컴파일러에서 보장해주지 않기 때문에 한 정적 변수가 다른 정적 변수에 안전하게 의존할 수도 없다.

 

게으른 초기화는 이런 문제를 해결해준다. 최대한 늦게 초기화되기 때문에, 그때쯤에는 클래스가 필요로 하는 정보가 준비되어 있다. 또한 순환 의존만 없다면 초기화 할때 다른 싱글턴을 참조해도 괜찮다.

 

 

싱글턴을 상속할 수 있다

파일 시스템 래퍼가 크로스 플랫폼을 지원해야 한다면 추상 인터페이스로 만든 뒤, 플랫폼마다 구체 클래스를 만들면 된다. 먼저 다음과 같이 상위 클래스를 만든다.

class FileSystem
{
public:
	virtual ~FileSystem() {}
	virtual char* readFile(char* path) = 0;
	virtual void  writeFile(char* path, char* contents) = 0;
};

이제 플랫폼별로 하위 클래스를 정의한다.

class PS3FileSystem : public FileSystem
{
public:
	virtual char* readFile(char* path)
	{
		// 소니의 파일 IO API를 사용한다.
	}
	virtual void writeFile(char* path, char* contents)
	{
		// 소니의 파일 IO API를 사용한다.
	}
};

class WiiFileSystem : public FileSystem
{
public:
	virtual char* readFile(char* path)
	{
		// 닌텐도의 파일 IO API를 사용한다.
	}
	virtual void writeFile(char* path, char* contents)
	{
		// 닌텐도의 파일 IO API를 사용한다.
	}
};

이제 FileSystem 클래스를 싱글턴으로 만든다.

class FileSystem
{
public:
	static FileSystem& instance();

	virtual ~FileSystem() {}
	virtual char* readFile(char* path) = 0;
	virtual void  writeFile(char* path, char* contents) = 0;

protected:
	FileSystem() {}
};

 

핵심은 인스턴스를 생성하는 부분이다.

FileSystem& FileSystem::instance()
{
#if PLATFORM == PLAYSTATION3
	static FileSystem* instance = new PS3FileSystem();
#elif PLATFORM == WII
	static FileSystem* instance = new WiiFileSystem();
#endif
	return *instance;
}

전처리기 지시문을 이용해서 간단하게 컴파일러가 시스템에 맞는 파일 시스템 객체를 만들게 할 수 있다. FileSystem::instance()를 통해서 파일 시스템에 접근하기 때문에, 플랫폼 전용 코드는 FileSystem 클래스 내부에 숨겨놓을 수 있다.

 

이만하면 파일 시스템 래퍼에 필요한 기능을 다 제공하는 셈이다. 안정적으로 작동하고, 어디에서나 접근할 수 있다.


싱글턴이 왜 문제라는 거지?

짧게 놓고 보면 싱글턴 패턴에 큰 문제는 없다. 하지만 다른 단기적인 설계 결정들과 마찬가지로 길게 놓고 보면 비용을 지불하게 된다. 꼭 필요하지 않은 곳에 싱글턴 패턴을 적용하면 다음과 같은 문제에 부딪히게 된다.

 

 

알고보니 전역변수

옛날에 비해서 게임이 점차 커지고 복잡해짐에 따라 설계와 유지보수성이 병목이 되기 시작했다. 하드웨어 한계보다는 생산성 한계 때문에 게임 출시가 늦어지는 시대가 되었다.

 

개발자들은 C++ 같은 언어로 갈아탔고, 이전 세대 개발자들이 힘들게 얻은 지혜를 써먹기 시작했다. 그런 지혜 중 하나가 전역 변수는 나쁘다는 것인데, 이유는 다음과 같다.

 

1. 전역 변수는 코드를 이해하기 어렵게 한다.

남이 만든 함수에서 버그를 찾아야 할 때, 함수가 전역 상태를 건드리지 않는다면 함수 코드와 매개변수만 보면 된다.

 

하지만, 예를 들어 SomeClass::getSomeGlobalData() 같은 코드가 있다면 전체 코드에서 SomeGlobalData에 접근하는 곳을 다 살펴봐야 상황을 파악할 수 있다. 프로그램이 커져 코드가 수백만 줄이 되는 경우에는 이 상황을 더더욱 골치아프게 한다.

 

2. 전역 변수는 커플링을 조장한다.

예를 들어, 유지보수하기 좋도록 코드를 느슨하게 결합해놓은 아키텍처를 아직 제대로 파악하지 못한 신입에게 '돌맹이가 땅에 떨어질 때 소리가 나게 하는' 작업을 첫 일감으로 줬다고 해보자. 기존 작업자들은 물리 코드와 사운드 코드 사이에 커플링이 생기는 것을 피하겠지만, 신입에게는 주어진 작업을 어서 끝내고 싶은 마음뿐이다. 안타깝게도 AudioPlayer 인스턴스에 전역으로 접근할 수 있다 보니, 신입이 #include 한 줄만 추가해도 신중하게 만들어놓은 아키텍처를 더럽힐 수 있다.

 

AudioPlayer 전역 인스턴스를 아예 만들지 않았더라면, 신입이 #include를 추가하더라도 어찌 해볼 방법이 없다. 쉽게 할 수 없다는 것은 두 모듈이 서로 몰라야 하고(디커플링) 다른 방법으로 문제를 해결해야 한다는 것을 분명하게 알려주는 신호이다. 인스턴스에 대한 접근을 통제함으로써 커플링을 통제할 수 있다.

 

3. 전역 변수는 멀티스레딩 같은 동시성 프로그래밍에 알맞지 않다.

싱글코어로 게임을 실행하던 시절은 끝난지 오래다. 멀티스레딩을 최대한 활용하지는 못하더라도, 최소한 멀티스레딩 방식에 맞게 코드를 만들어야 한다. 무엇인가를 전역으로 만들면 모든 스레드가 보고 수정할 수 있는 메모리 영역이 생기는 셈이다. 다른 스레드가 전역 데이터에 무슨 작업을 하는지 모를 때도 있다. 이러다 보면 교착상태, 경쟁상태(Data Race) 등 정말 찾기 어려운 동기화 버그가 생기기 쉽다.

 

 

싱글턴은 치료제라기보다는 진정제에 가깝다. 전역 상태 때문에 생길 수 있는 문제를 쭉 살펴보면, 어느 하나도 싱글턴 패턴으로 해결할 수 없다는 것을 알게 된다. 싱글턴 패턴은 클래스로 캡슐화된 전역 상태이기 때문이다. 

 

싱글턴은 문제가 하나뿐일 때도 두 가지 문제를 해결하려 든다

싱글턴은 '전역 접근'과 '한 개의 인스턴스'에 대한 문제를 모두 해결하려고 하는 방법이다. 다르게 말하면 저 둘 중 하나만 하려고 해도 둘 다 하게 된다라는 뜻이 된다.

 

보통은 '전역 접근'이 싱글턴을 선택하는 이유다. 로그를 남기는 클래스를 예로 들어보자. 게임 내 모듈에서 정보를 로그로 남길 수 있다면 편할 것이다. 하지만 모든 함수 안에 로그 클래스 인스턴스를 추가하면 코드가 번잡해지고 의도를 알아보기 어려워진다.

 

가장 간단한 해결책이 바로 싱글턴이다. 그러면 모든 함수에서 직접 로그 클래스에 접근하여 인스턴스를 얻을 수 있을 것이다. 하지만 의도치 않게 로그 객체를 하나만 만들 수 있다는 제한이 생긴다.

 

로그를 여러 파일에 나눠 쓸 수 있다면 좋을 것이다. 그러려면 분야별로 로그 클래스를 만들 수 있어야 하지만 로그 클래스가 싱글턴이다 보니 인스턴스를 여러 개 만들 수 없다. 그러면 로그 텍스트 파일은 서로의 로그가 섞여 뒤죽박죽 아수라장이 될것이고, 그 로그를 찾기위해 많은 양의 텍스트 파일을 직접 뒤져야 할것이다.

 

이런 설계 제약은 로그 클래스를 사용하는 모든 코드에 영향을 미치게 된다. 그리고 이 문제를 해결하기 위해 로그 클래스를 여러 개 만들 수 있게 바꾸려면, 클래스와 클래스를 사용하는 모든 코드를 바꿔줘야 한다. 편하게 접근할 수 있다는 장점이 오히려 단점이 되어 버렸다.

 

 

게으른 초기화는 제어할 수 없다

시스템을 초기화할 때 메모리 할당, 리소스 로딩 등 할 일이 많다 보니 시간이 꽤 걸릴 수 있다. 만약 오디오 시스템 초기화 시간이 몇백ms 이상 걸린다면 초기화 시점을 제어해줘야 한다. 처음 소리를 재생할 때 게으른 초기화를 하게 만들면 인게임 내에서 초기화가 시작되는 바람에 프레임이 떨어지고 게임이 버벅댈 수 있다.

 

또한 게임에서는 메모리 단편화를 막기 위해 힙에 메모리를 할당하는 방식을 세밀하게 제어하는 게 보통이다. 시스템 초기화를 할때 상당한 메모리를 힙에 할당한다면, 힙 어디에 메모리를 할당할지를 제어할 수 있도록 적절한 초기화 시점을 찾아야 한다.

 

이런 문제점들을 피하기 위해 싱글턴 패턴을 다음과 같이 구현 해볼수 있다.

class FileSystem
{
public:
	static FileSystem& instance() { return instance_; }
private:
	FileSystem();
	static FileSystem instance_;
};

간단히 싱글턴 대신 단순한 정적 클래스를 하나 만든 셈이다. 이러면 게으른 초기화 문제를 해결 할 수 있다. 하지만 정적 인스턴스를 사용하게 되면 다형성을 사용할 수 없다. 또한 인스턴스가 필요 없게 되어도 메모리를 해제 할 수 없다.

 

만약 정적 클래스만으로 다 해결이 가능하다면 instance() 메서드를 제거하고 정적 함수를 대신 사용하는 게 낫다. Foo::bar()는 Foo::instance()::bar()보다 간단하고, 정적 메모리에 접근한다는 걸 더 분명하게 보여준다.


대안

싱글턴을 안쓴다면 어떤 대안이 있을까? 어떤 문제를 풀려고 하느냐에 따라 몇 가지 대안이 있지만, 그 전에 먼저 다음을 생각해보자.

 

 

클래스가 꼭 필요한가?

게임 코드에서 보통 다른 객체 관리용으로만 존재하는 '관리자(manager)'가 많이 보인다. Monster, MonsterManager, Particle, ParticleManager, 등 모든 클래스에 관리자 클래스가 붙는 경우가 그러하다.

 

관리 클래스가 필요한 경우도 있지만 OOP를 제대로 이해하지 못해 만드는 경우도 많다. 다음 클래스를 보자.

class Bullet
{
public:
	int getX() const { return x_;}
	int getY() const { return y_;}
	void setX(int x) { x_ = x; }
	void setY(int y) { y_ = y; }

private:
	int x_;
	int y_;
};

class BulletManager
{
public:
	Bullet* create(int x, int y)
	{
		Bullet* bullet = new Bullet();
		bullet->setX(x);
		bullet->setY(y);
		return bullet;
	}

	bool isOnScreen(Bullet& bullet)
	{
		return	bullet.getX() >= 0 &&
				bullet.getY() >= 0 &&
				bullet.getX() < SCREEN_WIDTH &&
				bullet.getY() < SCREEN_HEIGHT;
	}

	void move(Bullet& bullet)
	{
		bullet.setX(bullet.getX() + 5);
	}
};

언뜻 보면 Bullet을 쓰려면 BulletManager 싱글턴을 필요로 하겠다는 생각이 들 수 있다. 그렇다면 관리자 클래스 인스턴스는 몇 개 필요할까?

 

정답은 0개이다. 아래와 같이 만들면 관리자 클래스에 대한 '싱글턴' 문제를 해결할 수 있다.

class Bullet
{
public:
	Bullet(int x, int y) : x_(x), y_(y) {}

	bool isOnScreen()
	{
		return	x_ >= 0 && x_ < SCREEN_WIDTH && 
				y_ >= 0 && y_ < SCREEN_HEIGHT;
	}

	void move() { x_ += 5; }

private:
	int x_;
	int y_;
};

서툴게 만든 싱글턴은 다른 클래스에 기능을 더해주는 도우미인 경우가 많다. 가능하다면 도우미 클래스에 있던 작동 코드를 모두 원래 클래스로 옮기자. 객체가 스스로를 챙기게 하는 게 바로 OOP이다. 

 

 

오직 한 개의 클래스 인스턴스만 갖도록 보장하기

싱글턴 패턴이 해결하려는 첫 번째 문제이다. 앞서 본 파일 시스템 예제에서 클래스 인스턴스를 하나만 있도록 보장하는 건 중요하다. 하지만 그렇다고 누구나 어디에서든 인스턴스에 접근할 수 있게 만들고 싶은 건 아닐 수 있다. 이런 경우 전역에서 누구나 접근할 수 있게 만들면 구조가 취약해진다.

 

전역 접근 없이 클래스 인스턴스만 한개로 보장할 수 있는 방법이 몇 가지 있다.

class FileSystem
{
public:
	FileSystem()
	{
		assert(!instantiated_);
		instantiated_ = true;
	}
	~FileSystem()
	{
		instantiated_ = false;
	}

private:
	static bool instantiated_;
};

이 클래스는 어디서나 인스턴스를 생성할 수 있지만, 인스턴스가 둘 이상 되는 순간 단언문(assert)에 걸린다. 단일 인스턴스는 보장하지만 클래스를 어떻게 사용할지에 대해서는 강제하지 않는다. 

 

다만 이 방식은 싱글턴과 다르게 런타임에 인스턴스 개수를 확인한다는 단점이 존재한다.

 

 

인스턴스에 쉽게 접근하기

쉬운 접근성은 싱글턴을 선택하는 가장 큰 이유이다. 싱글턴을 사용하면 여러 곳에서 사용해야하는 객체에 쉽게 접근할 수 있다. 이런 편리함에는 원치 않는 곳에서도 쉽게 접근할 수 있다는 비용이 따른다.

 

변수는 작업 가능한 선에서 최대한 적은 범위로 노출하는게 일반적으로 좋다. 그럴수록 코드를 볼때 머릿속에 담아둬야 할 범위가 줄어든다. 객체에 접근할 수 있는 다른 방법을 고민해보자.

 

넘겨주기

객체를 필요로 하는 함수에 인수로 넘겨주는 게 가장 쉬우면서도 최선인 경우가 많다.

 

객체를 렌더링하는 함수를 생각해보자. 렌더링하려면 렌더링 상태를 담고 있는 그래픽 디바이스 대표 객체에 접근할 수 있어야 한다. 이럴 때는 일반적으로 모든 렌더링 함수에서 context같은 이름의 매개변수를 받는다. 

 

반면, 어떤 객체는 메서드 시그니처에 포함되지 않는다. 예를 들어 AI관련 함수에서도 로그를 남길 수 있어야 하지만, AI의 핵심이 아닌 로그 객체를 인수에 추가하기는 좀 어색하다. 다른 방법을 찾아보자.

 

상위 클래스로부터 얻기

많은 게임에서 클래스를 대부분 한 단계만 상속할 정도로 상속 구조를 얕고 넓게 가져간다. 몬스터나 다른 게임 내 객체가 상속받는 GameObject라는 상위 클래스가 있다고 해보자. 이런 구조에서는 게임 코드의 많은 부분이 '단말'에 해당하는 하위 클래스에 있다. 즉, 많은 클래스에서 같은 객체, 즉 GameObject 상위 클래스에 접근할 수 있다. 이 점을 활용하면 아래와 같이 만들 수 있다.

class GameObject
{
protected:
	Log& getLog() { return log_; }

private:
	static Log& log_;
};

class Enemy : public GameObject
{
	void doSomething()
	{
		getLog().write("I can log!");
	}
};

이러면 GameObject를 상속받은 코드에서만 getLog()를 통해서 로그 객체에 접근할 수 있다. 

 

이미 전역인 객체로부터 얻기

전역 상태를 모두 제거하기란 너무 이상적이다. 결국에는 Game이나 World같이 전체 게임 상태를 관리하는 전역 객체가 커플링 되어있기 마련이다. 

 

기존 전역 객체에 빌붙으면 전역 클래스 개수를 줄일 수 있다. Log, FileSystem, Audio, Player를 각각 싱글턴으로 만드는 대신 이렇게 해보자. 

class Game
{
public:
	static Game& instance() { return instance_; }

	Log& getLog() { return *log_; }
	FileSystem& getFileSystem() { return *fileSystem_; }
	AudioPlayer& getAudioPlayer() { return *audioPlayer_; }
    
	// log 등을 설정하는 함수들...
private:
	static Game instance_;
	Log* log_;
	FileSystem* fileSystem_;
	AudioPlayer* audioPlayer_;
};

이제 Game클래스 하나만 전역에서 접근 할 수 있다. 다른 시스템에 접근하려면 다음과 같이 함수를 호출하면 된다.

Game::instance().getAudioPlayer().play(VERY_LOUD_BANG);

나중에 Game 인스턴스를 여러 개 지원하도록 구조를 바꿔도, Log, FileSystem, AudioPlayer는 영향받지 않는다. 더 많은 코드가 Game 클래스에 커플링된다는 단점은 있다. 사운드만 출력하기 위해 AudioPlayer 객체에 접근하기 위해서는 Game 클래스를 알아야 한다.

 

이런 문제는 여러 방법으로 해결이 가능하다. 이미 Game 클래스를 알고 있는 코드에서는 AudioPlayer를 Game 클래스로부터 받아서 쓰면 된다. Game 클래스를 모르고 있는 코드에서는 앞서 본 것처럼 넘겨주기나 상위 클래스로부터 얻기를 통해 AudioPlayer에 접근하면 된다.


자료의 출처는 https://gameprogrammingpatterns.com/singleton.html입니다.

반응형
Comments