오직 한개의 인스턴스(객체)만을 갖도록 보장하고, 이에대한 전역적인 접근점을 제공한다.
싱글턴은 부적당한 곳에서 사용하면 총상에 부목을 대는 것만큼이나 쓸모가 없다. 워낙 남용되는 패턴이다 보니 싱글턴을 회피할수 있는 방법을 주로 다루겠다. 그전에 싱글턴에 대해서 살펴보자.
싱글턴 패턴
오직 한개의 인스턴스만 갖도록 보장하는 것이 싱글턴이다. 인스턴스가 여러개면 제대로 작동하지 않는 상황이 자연스럽게 늘어나게 된다. 외부 시스템과 상호작용하면서 전역상태를 관리하는 클래스 같은것이다. 파일 시스템을 관리하느 API 래핑 클래스가 있다고 하자. 작업을 완료하느데 시간이 걸리것이다. 그래서 이 클래스는 비동기로 만들어준다. 즉 여러작업을 동시에 진행할 수 있도록 서로 조율할 것이다. 한쪽에서는 파일을 생성하고 한쪽에서는 생성한 파일을 삭제하려고 하면, 래퍼 클래스가 두 작업을 다 파악해서 서로 간섭하지 못하게 해야한다.
이를 위해서 파일 시스템 클래스로 들어온 호출이 이전 작업 전체에 대해서 접근할 수 있어야 한다. 아무데서나 파일시스템 클래스 인스턴스를 만들 수 있다면 다른 인스턴스에서 어떤 작업을 진행중인지 알수 없다. 이를 싱글턴 클래스로 인스턴스를 하나만 가지도록 강제 할수 있다.
전역 접근점을 제공
로깅, 콘텐츠 로딩, 게임 저장 등 여러 내부에서 파일 시스템 을 이용한 API를 사용할것이다. 이들 시스템에서 클래스 인스턴스를 따로 생성할수 없다면, 파일 시스템에는 어떻게 접근해야하는가?
여기에 대한 해결책을 싱글턴이 제공한다. 하나의 인스턴스만 생성해버리고 전역변수혀앹로 메서드를 제공한다면 어디에서든 호출 할수 있을 것이다.
class FileSystem
{
public:
static FileSystem& instance()
{
// Lazy initialize.
if (instance_ == NULL) instance_ = new FileSystem();
return *instance_;
}
private:
FileSystem() {}
static FileSystem* instance_;
};
C++
복사
instance_ 정적 멤버 변수는 클래스 인스턴스를 저장한다. 생성자가 private 이기 떄문에 외부에서는 해당 객체를 생성할수 없다. public 에있는 instance()만을 이용해서 접근이 가능하다.
최초로 한번만 객체가 생성이 되고 해당 인스턴스에 어디에서든지, 언제든지 접근이 가능한 형태를 제공해준다. (싱글턴)
class FileSystem
{
public:
static FileSystem& instance()
{
static FileSystem *instance = new FileSystem();
return *instance;
}
private:
FileSystem() {}
};
C++
복사
c++ 11 에서는 정적 지역 변수 초기화 코드가 멀티 스레드 환경에서도 딱 한번만 실행되어야 한다. 즉 최신 C++ 컴파일러라면 스레드에서 안전하다.
싱글턴은을 왜 사용 하는가?
이만하면 너무 좋아 보인다. 어디에서든 사용할수 있고 번거롭게 인자를 주고 받지도 않는다. 인스턴스를 여러개 만들지 않기 때문에 상태가 잘못 동작하는 경우도 거의 없다. 장점이 너무 많다.
한번도 사용하지 않는 다면 아예 인스턴스를 생성하지 않는다.
메모리와 CPU 사용량을 줄이는건 도움이 된다. 싱글턴은 처음 사용 할때 초기화되므로, 게임내에서 전혀 사용되지 않늗나면 아예 초기화되지 않는다.
런타임에 초기화된다.
싱글턴은 최대한 늦게 초기화 되기 때문에, 그때즘에는 클래스가 필요로하는 정보들이 이미 준비되어 있다. 이런걸 게으른 초기화라고 한다. 순환 의존만 없다면 초기화 할때 다른 싱글턴을 참조해도 된다.
싱글턴은 상속 할 수 있다.
이 방법은 강력함에도 불고하고 잘 알려져 있지 않다. 파일 시스템 래퍼가 크로스 플랫폼을 지원해야 한다면 추상 인터페이스(순수가상함수) 만든뒤, 플랫폼마다 구체 클래스를 만들면된다. 먼저 다음과 같이 상위 클래스를 만든다.
class FileSystem
{
public:
virtual ~FileSystem() {}
virtual char* readFile(char* path) = 0;
virtual void writeFile(char* path, char* contents) = 0;
};
C++
복사
하위클래스르 만들어보자
class PS3FileSystem : public FileSystem
{
public:
virtual char* readFile(char* path)
{
// Use Sony file IO API...
}
virtual void writeFile(char* path, char* contents)
{
// Use sony file IO API...
}
};
class WiiFileSystem : public FileSystem
{
public:
virtual char* readFile(char* path)
{
// Use Nintendo file IO API...
}
virtual void writeFile(char* path, char* contents)
{
// Use Nintendo file IO API...
}
};
C++
복사
파일시스템을 싱글턴으로 만든다.
class FileSystem
{
public:
static FileSystem& instance();
virtual ~FileSystem() {}
virtual char* readFile(char* path) = 0;
virtual void writeFile(char* path, char* contents) = 0;
protected:
FileSystem() {}
};
C++
복사
FileSystem& FileSystem::instance()
{
#if PLATFORM == PLAYSTATION3
static FileSystem *instance = new PS3FileSystem();
#elif PLATFORM == WII
static FileSystem *instance = new WiiFileSystem();
#endif
return *instance;
}
C++
복사
이만하면 파일 시스템 래퍼에 필요한 기능을 다 제공한다. 안정적으로 작동하고, 어디에서나 접근 가능하다.
그럼에도 불구하고 싱글턴은 무엇이 문제인가?
짧게 놓고 보면 싱글턴패턴은 문제가 없어보인다. 하지만 단기적인 계획일 경우에 한정 되어 있다. 설계 과정에서보면 시간이 길어질수록 크나큰 비용을 지불하는 패턴이다.
알고 보니 전역변수
두 세명이 창고에서 게임을 만들던 시절에는 소프트웨어 엔지니어링 이론보다는 하드웨어 성능을 얼마나 끌어낼수 있는가가 중요했다. 정적변수와 전역변수를 마구마구 사용해도 사람이 없기 때문에 큰 문제없이 완성해서 출시 할수 있다. 왜? 사람이적으니까 마구마구사용해도 서로 다 알고 사용하는 경우가 많으니까! 하지만 점점 게임이 규모가 커지고 컨텐츠가 많아지고 복잡해진다. 만드는것도 중요하지만 시간에 따라서 유지보수하는것도 중요해졌다. 생상성의 한계 때문에 게임이 출시가 늦어지는 경우가 많다.
전역변수는 코드를 이해하기 어렵게 한다.
남이 만든 함수에서 버그를 찾으려면, 함수가 전역 상태를 건드리지 않는다면 함수 코드와 매개변수만 보면된다. 하지만 전역함수나, 정적함수 SomeClass::getSomeGlobalData() 이런 코드가 들어가 있으면
저코드를 사용하는 모든 부분들을 다 체크해줘야하 할것이다. 수백만줄이라고 하면 새벽 3시에 퇴근하는 나의모습을 보고 있을것이다.
전역변수는 커플링을 조장한다.
전역변수는 멀티스레딩 같은 동시성 프로그래밍에 알맞지 않다.
싱글코어로 게임하던 시절은 끝난지 오래다. 멀티스레딩을 최대한 활용하지는 못하더라도 최소한의 멀티스레딩 방식에 맞게 코드를 만들어야한다. 무엇인가를 전역으로 만들면 모든 스레드가 보고 수정할수 있는 메모리 영역이 생기는 셈이다. 다른 스레드가 전역 데이터에 무슨작을 하는지 모를 때도 있다. 이러다보면 교착상태, 경쟁상태등 정말 찾기 어려운 스레드 버그를 마주할게 될것이다.
전역변수, 즉 싱글턴이 얼마나 많은 문제를 야기시킬수 있는지 알게 되었다. 그럼 전역상태 없이 어떻게 게임프로그래밍을 설계 해야 하는가?
앞으로 우리가 배울 패턴들이 해당문제해결에 많은 도움을 준다. 싱글턴은 치료제보다는 진정제 가깝다. 전역 상태에 문제를 쭉 살펴보면 어느하나도 싱글턴을 패턴으로 해결할수 없다는 것을 알게된다.
왜냐하면 싱글턴은 결국 전역상태로 만들어진 객체이기 때문이다.
싱글턴은 문제가 하나일 뿐인대도 두가지 문제를 풀려고한다.
‘한개의 인스턴스’ 와 ‘전역 접근’ 중에서 보통은 전역접근이 싱글턴을 사용하는 이유가 된다.
로그를 작성하는 클래스를 싱글턴으로 만들어 봅시다. 모든 함수인수에 log 클래스 인스턴스를 추가하면 함수가 전부다 복잡해진다. log클래스를 싱글턴으로 만들면 편리해진다. 다만 의도치 않게 객체가 1개뿐이게 된다.
처음에는 별 문제가 되지 않지만 로그 파일 하나에 다쓴다면 인스턴스도 하나만 있으면 된다. 개발 진행되다 보면 개발팀 모두가 각자 필요한 정보를 로그로 남기다 보니 로그 파일이 뒤섞이게 된다. 뒤죽박죽된 로그파일 안에서 원하는 정보 하나를 찾기가 어려워진다.
로그 파일을 어래골 나눠 쓸수 있다면 좋을 것이다. 그러러면 각 기능(오디오, 게임플레이, 네트워크, ui)각각 로그파일 나누면 된다. log 클래스 객체가 여러개로 바꾸어주어야한다.
Log::Instance().write(”Some event”); 엄청난 대공사가 되어버린다.
게으른 초기화는 제어할 수 없다.
싱글턴은 사용할때 초기화 된다. 반대로 생각하면 내가 초기화되는시점을 정확하게 제어하기가 어렵다. 게임같은 경우는 초기화를 런타임하는경우가 드물다. 왜냐하면 리소스가 굉장히 용량이 크고 많다. 게임은 미리 초기화해두는 경우가 많다. 실시간으로 힙메모리를 할당하면 성능상이유로 버벅거림이 생길수 있다. 그래서 거의 모든게임들은 게으른 초기화를 사용하지 않는다.
class FileSystem
{
public:
static FileSystem& instance() { return instance_; }
private:
FileSystem() {}
static FileSystem instance_;
};
C++
복사
이렇게하면 해당 문제를 해결 할수 있지만, 싱글턴이 전역변수보다 나은 점을 몇개 포기해야한다.
싱글턴 대신에 정적 클래스를 하나 만든것이다. 이것도 나쁘지는 않지만 , 정적클래스만으로 다 해결할 수 있다면 아예 instance()메서드를 제거하고 정적함수(전역함수 비슷한 기능)을 사용하면된다.
대안
그러면 어떤 대안이 있을까?
클래스가 꼭 필요한가?
애매하게 다른 객체를 관리하느 용도로 사용하는 싱글턴 패턴이 많다. 몬스터, 몬스터매니저, 파티클, 파티클 매니저, 사운드, 사운드 매니저, 리소스, 리소스 매니저등등 관리 클래스가 필요할 때도 있지만 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_, 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.getX() < SCREEN_WIDTH &&
bullet.getY() >= 0 &&
bullet.getY() < SCREEN_HEIGHT;
}
void move(Bullet& bullet)
{
bullet.setX(bullet.getX() + 5);
}
};
C++
복사
언뜻보면 bulletManager를 싱글톤으로 만들어야겠다는 생각이 들 수도 있다. 싱글턴을 사용하지 않아 버리면된다!!!
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_, y_;
};
C++
복사
매니저 없이 만들어 버릴수도 있다. 서툴게 만든 싱글턴 클래스는 다른클래스의 기능을 도와주는 정도로 사용되는 경우가 많다. 가능하다면 도우미 클래스 있던 코드를 모두 원본 클래스 옮겨서 총알 자신이 자신을 만들고 이동하고 할수 있도록 만들어 버리면 싱글턴의 문제들이 전부 해결된다.
오직 한개의 클래스르 인스턴스만 갖도록 보장하기
앞서 이야기한 파일 시스템 에서 클래스 객체를 한개만 갖도록 보장하는건 중요하다. 그렇다고 누구나 어디에서나 인스턴스에 접근할수 있게 하고 싶은건 아닐 수도 있다. 특정 코드에서만 접근할 수 있게 만들거나 아예 클래스를 private 멤버 변수로 만들고 싶을 수 있다. 이럴 때 누구나 접근 가능하게 만들면 구조가 취약해진다.
전역 접근 없이 클래스 인스턴스를 한개로 보장할수 있는 방법 있다.
class FileSystem
{
public:
FileSystem()
{
assert(!instantiated_);
instantiated_ = true;
}
~FileSystem() { instantiated_ = false; }
private:
static bool instantiated_;
};
bool FileSystem::instantiated_ = false;
C++
복사
만약에 객체를 추가적으로 만든다면 생성자에 assert가 실행되어 오류를 반환하고 프로그램 종료시켜 프로그래머한테 알리게 된다.
인스턴스에 쉽게 접근하기
전역접근이 싱글턴을 사용하는 큰 이유였다.
이를 해결할 방법을 알아보자.
넘겨주기(전달인자)
객체를 필요로 하는 함수에 인수로 넘겨주는게 가장 쉬우면서도 취선인 경우가 많다. 번거롭더라도 무작정 외면하지말고 인자를 넣어서 사용하는걸 고민해보자.
상위 클래스에서 얻기(상속)
게임에서 많은 객체들은 상속구조를 활용한다. 부모클래스에 모든객체가 사용할수 있는 객체를 넣어두면 해당 상속을 받은 자식 객체들은 전부 전역처럼 사용이 가능하다.
class GameObject
{
protected:
Log& getLog() { return log_; }
private:
static Log& log_;
};
class Enemy : public GameObject
{
void doSomething()
{
getLog().write("I can log!");
}
};
C++
복사
이미 전역으로 만들어진 객체에서 얻어오기
기존에 만들어지 전역객체에 우리가 추가할 객체를 넣어주고 해당객체를 통해서 가져다 사용하자.
class Game
{
public:
static Game& instance() { return instance_; }
// Functions to set log_, et. al. ...
Log& getLog() { return *log_; }
FileSystem& getFileSystem() { return *fileSystem_; }
AudioPlayer& getAudioPlayer() { return *audioPlayer_; }
...
private:
static Game instance_;
Log *log_;
FileSystem *fileSystem_;
AudioPlayer *audioPlayer_;
};
C++
복사
호출은 요렇게 하면된다.
Game::instance().getAudioPlayer().play(VERY_LOUD_BANG);
C++
복사
서비스 중재자로부터 얻기
이부분은 뒤에 서비스 중개자 패턴에대해서 자세히 살펴볼것이다.
싱글턴에서 남은 것
그렇다면 ? 진짜로 싱글턴을 필요로 할 때는 언제인가? 인스턴스를 하나로 제한하고 싶을때는 정적클래스를 쓰거나, 클래스 생성자에 정적 플래그를 둬서 런타임 인스턴스 개수를 검사하는 방법을 쓸수 있다. 하지만 모든것에는 정답은 없으니 상황과 추후 유지보수를 고려하여 적당히 활용해야 한다.