오직 한개의 인스턴스(객체)만을 갖도록 보장하고, 이에대한 전역적인 접근점을 제공한다.
싱글턴은 부적당한 곳에서 사용하면 총상에 부목을 대는 것만큼이나 쓸모가 없다. 워낙 남용되는 패턴이다 보니 싱글턴을 회피할수 있는 방법을 주로 다루겠다. 그전에 싱글턴에 대해서 살펴보자.
싱글턴 패턴
오직 한개의 인스턴스만 갖도록 보장하는 것이 싱글턴이다. 인스턴스가 여러개면 제대로 작동하지 않는 상황이 자연스럽게 늘어나게 된다. 외부 시스템과 상호작용하면서 전역상태를 관리하는 클래스 같은것이다. 파일 시스템을 관리하느 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++
복사
서비스 중재자로부터 얻기
이부분은 뒤에 서비스 중개자 패턴에대해서 자세히 살펴볼것이다.
싱글턴에서 남은 것
그렇다면 ? 진짜로 싱글턴을 필요로 할 때는 언제인가? 인스턴스를 하나로 제한하고 싶을때는 정적클래스를 쓰거나, 클래스 생성자에 정적 플래그를 둬서 런타임 인스턴스 개수를 검사하는 방법을 쓸수 있다. 하지만 모든것에는 정답은 없으니 상황과 추후 유지보수를 고려하여 적당히 활용해야 한다.
원문 전체 해설·코드·도해
위의 수업용 정리와 실습을 먼저 읽은 뒤, 아래에서 책의 원문 흐름·모든 코드·원본 도해·각주를 빠짐없이 확인할 수 있습니다.
오직 한 개의 클래스 인스턴스만 갖도록 보장하고, 이에 대한 전역적인 접근점을 제공합니다.
— 『GoF의 디자인 패턴』, 한국어판 181쪽
이번 장은 어떻게 하면 패턴을 안 쓸 수 있는지를 보여 준다는 점에서 다른 장과 정반대입니다. GoF의 싱글턴 패턴은 의도와 달리 득보다 실이 많습니다. GoF도 남용하지 말라고 강조했지만, 게임 개발자 중 귀담아듣는 사람은 많지 않았습니다.
다른 패턴도 마찬가지지만, 싱글턴을 부적절한 곳에 사용하면 총상에 부목을 대는 것만큼이나 쓸모없습니다. 워낙 남용되는 패턴이므로 이 장에서는 피하는 방법을 주로 다루겠습니다. 그래도 먼저 싱글턴이 무엇인지 살펴보겠습니다.
원주 1: 프로그래밍 업계가 C에서 객체지향 프로그래밍으로 넘어가던 시절, ‘원하는 인스턴스에 어떻게 접근할 것인가’가 문제가 되었습니다. 호출하고 싶은 메서드는 있는데 그 메서드를 제공하는 객체 인스턴스에 쉽게 접근할 방법이 없었습니다. 이때 싱글턴, 다시 말해 전역 변수는 쉬운 해결책이었습니다.
6.1 싱글턴 패턴(Singleton Pattern)
GoF의 정의를 쉼표 앞뒤로 나누어 살펴보겠습니다.
오직 한 개의 클래스 인스턴스만 갖도록 보장하기
인스턴스가 여러 개이면 제대로 작동하지 않는 상황이 종종 있습니다. 외부 시스템과 상호작용하면서 전역 상태를 관리하는 클래스가 한 예입니다.
파일 시스템 API를 감싸는 클래스를 생각해 보겠습니다. 파일 작업은 완료하는 데 시간이 걸리므로 이 클래스는 비동기로 동작해야 합니다. 여러 작업이 동시에 진행될 수 있으므로 작업들을 조율해야 합니다.
한쪽에서 파일을 만들고 다른 쪽에서 방금 만든 파일을 삭제하려 한다면, 래퍼 클래스가 두 작업을 모두 파악하고 서로 간섭하지 못하게 해야 합니다.
이를 위해 파일 시스템 클래스로 들어온 호출은 이전 작업 전체에 접근할 수 있어야 합니다. 아무 곳에서나 파일 시스템 클래스의 인스턴스를 만들 수 있다면 다른 인스턴스에서 진행하는 작업을 알 수 없습니다. 원문은 이 클래스를 싱글턴으로 만들면 인스턴스를 하나만 갖도록 컴파일 단계에서 강제할 수 있다고 설명합니다.
전역 접근점 제공하기
로깅, 콘텐츠 로딩, 게임 저장 같은 여러 내부 시스템에서 파일 시스템 래퍼를 사용할 것입니다. 이들이 인스턴스를 따로 만들 수 없다면 파일 시스템에는 어떻게 접근할까요?
싱글턴 패턴은 이 문제도 해결합니다. 인스턴스를 하나만 만드는 데 더해 그 인스턴스에 전역에서 접근하는 메서드를 제공합니다. 누구나 어디에서든 우리가 만든 인스턴스에 접근할 수 있습니다. 다음은 이를 구현한 클래스입니다.
class FileSystem {
public:
static FileSystem& instance() {
// 게으른 초기화
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++ 컴파일러로 컴파일하면 이 코드는 앞의 예제와 달리 스레드 안전하게 초기화됩니다.
원주 2·옮긴이: cppreference.com의 ‘Static local variables’ 부분과 MSDN의 ‘Magic statics’ 부분을 참고합니다. 원문에 실린 단축 주소는 각각 http://goo.gl/YZhFhb, https://goo.gl/PCYsQa입니다.
원주 3: 초기화가 스레드 안전하다는 것과 싱글턴 클래스 자체가 스레드 안전하다는 것은 전혀 다른 이야기입니다. 이 코드는 스레드 안전하게 초기화하는 것까지만 보장합니다.
하나의 인스턴스와 전역 접근은 별개의 결정입니다.
게임에 오디오 장치가 하나라는 사실만으로 모든 코드가 전역 접근자를 호출해야 하는 것은 아닙니다. 객체를 하나만 만든 채 필요한 시스템에 참조를 전달할 수도 있습니다. 아래에서 이 두 결정을 나누어 생각해 보겠습니다.
flowchart LR A["싱글턴을 선택하기 전"] --> B["하나여야 하는가?"] A --> C["전역 접근이 필요한가?"] A --> D["수명을 누가 관리하는가?"] A --> E["테스트에서 바꿀 수 있는가?"]
Mermaid
복사
예제의 범위: 위 코드는 접근자와 초기화 방식을 설명하는 예제입니다. 실제 C++ 클래스에서 복제까지 막으려면 복사·이동 정책도 따로 정해야 합니다. 생성자를 private으로 두는 것만으로 모든 복제 경로를 설명한 것은 아닙니다.
6.2 싱글턴을 왜 사용하는가?(Why Use a Singleton?)
여기까지는 더할 나위 없어 보입니다. 파일 시스템 래퍼를 번거롭게 인수로 주고받지 않아도 어디에서나 접근할 수 있습니다. 인스턴스를 여러 개 만들 수 없으므로 상태가 엉망이 될 일도 없습니다. 장점은 더 있습니다.
한 번도 사용하지 않으면 인스턴스를 만들지 않습니다
메모리와 CPU 사용량을 줄이는 일은 언제든 환영입니다. 싱글턴은 처음 사용할 때 초기화되므로, 게임에서 전혀 사용하지 않으면 아예 초기화되지 않습니다.
런타임에 초기화됩니다
싱글턴의 대안으로 정적 멤버 변수를 많이 사용합니다. 저자도 단순한 방식을 좋아하므로 가능하다면 싱글턴 대신 정적 클래스를 사용하는 편입니다. 하지만 정적 멤버 변수에는 자동 초기화(automatic initialization)라는 문제가 있습니다.
원문이 다루는 정적 변수는 컴파일러가 main()을 호출하기 전에 초기화하므로, 프로그램 실행 후에 알게 되는 정보를 활용할 수 없습니다. 파일에서 읽은 설정 값이 그 예입니다. 정적 변수의 초기화 순서도 컴파일러가 보장해 주지 않으므로, 한 정적 변수가 다른 정적 변수에 안전하게 의존할 수도 없습니다.
게으른 초기화는 이 문제를 해결해 줍니다. 싱글턴은 최대한 늦게 초기화되므로 그때에는 클래스에 필요한 정보가 준비되어 있습니다. 순환 의존만 없다면 초기화할 때 다른 싱글턴을 참조해도 괜찮습니다.
원주 4·옮긴이: 자동 초기화를 걱정하지 않고 정적 변수로 싱글턴을 만드는 방법으로 마이어스 싱글턴이 있습니다. 『More Effective C++』(정보문화사, 2007)의 ‘항목 26: 클래스 인스턴스의 개수를 의도대로 제한하는 방법’을 참고합니다.
싱글턴을 상속할 수 있습니다
강력하지만 잘 알려지지 않은 방법입니다. 파일 시스템 래퍼가 여러 플랫폼을 지원해야 한다면 추상 인터페이스를 만든 다음 플랫폼별 구체 클래스를 만들면 됩니다. 먼저 상위 클래스를 정의합니다.
원주 5·옮긴이: 싱글턴 상속 기법은 단위 테스트용 모의 객체(mock object)를 만들 때도 유용합니다.
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) {
// 소니의 파일 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를 사용한다...
}
};
C++
복사
이제 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() {}
};
C++
복사
핵심은 인스턴스를 생성하는 부분입니다.
FileSystem& FileSystem::instance() {
#if PLATFORM == PLAYSTATION3
static FileSystem *instance = new PS3FileSystem();
#elif PLATFORM == WII
static FileSystem *instance = new WiiFileSystem();
#endif
return *instance;
}
C++
복사
#if 같은 전처리기 지시문을 이용하면 컴파일러가 해당 시스템에 맞는 파일 시스템 객체를 만들게 할 수 있습니다. 호출부는 FileSystem::instance()로 접근하므로 플랫폼 전용 코드를 FileSystem 내부에 숨길 수 있습니다.
이 정도면 파일 시스템 래퍼에 필요한 기능을 모두 제공하는 셈입니다. 안정적으로 작동하고 어디에서나 접근할 수 있습니다. 이제 소스 코드 저장소에 코드를 넣고 콜라나 마시러 가도 될 것 같습니다.
6.3 싱글턴이 왜 문제일까요?(Problems with Singletons)
짧게 보면 싱글턴 패턴에는 큰 문제가 없습니다. 하지만 다른 단기적인 설계 결정과 마찬가지로 장기적으로는 비용을 지불하게 됩니다. 꼭 필요하지 않은 곳에 적용하면 다음 문제들과 마주칩니다.
알고 보니 전역 변수입니다
두세 명이 창고에서 게임을 만들던 시절에는 소프트웨어 공학 이론보다 하드웨어 성능을 얼마나 더 끌어낼 수 있느냐가 중요했습니다. C와 어셈블리어로 전역 변수와 정적 변수를 마음껏 사용해도 멋진 게임을 출시할 수 있었습니다.
게임이 커지고 복잡해지면서 설계와 유지보수성이 병목이 되기 시작했습니다. 하드웨어의 한계보다 생산성의 한계 때문에 출시가 늦어지는 시대가 된 것입니다. 개발자들은 C++ 같은 언어로 옮겨 갔고, 이전 세대가 어렵게 얻은 지혜를 활용하기 시작했습니다. 그중 하나가 ‘전역 변수는 나쁘다’는 것입니다.
전역 변수는 코드를 이해하기 어렵게 합니다
다른 사람이 만든 함수에서 버그를 찾는다고 해 보겠습니다. 함수가 전역 상태를 건드리지 않는다면 함수 코드와 매개변수만 보면 됩니다.
반면 함수에 SomeClass::getSomeGlobalData() 같은 코드가 있으면 전체 코드에서 SomeGlobalData에 접근하는 곳을 모두 살펴봐야 상황을 파악할 수 있습니다. 정적 변수 값을 이상하게 바꾸는 곳을 찾으려고 수백만 줄을 새벽 3시가 넘도록 뒤지다 보면 전역 상태가 정말 싫어질 것입니다.
원주 6: 원문은 전역 상태에 접근하거나 수정하지 않는 함수를 ‘순수(pure) 함수’라고 설명합니다. 순수 함수는 이해하기 쉽고 컴파일러가 최적화하기도 쉽습니다. 호출 결과를 저장했다가 같은 입력이 들어오면 저장한 값을 돌려주는 메모이제이션(memoization) 같은 최적화도 할 수 있습니다. 순수 함수만으로 개발하기는 쉽지 않지만 장점이 많아 하스켈 같은 순수 함수형 언어도 만들어졌습니다.
순수 함수라는 말의 범위
전역 상태를 건드리지 않는 것은 유용한 출발점입니다. 메모이제이션이 성립하려면 같은 입력에서 같은 결과가 나와야 하며, 파일 쓰기처럼 결과 값 이외의 관찰 가능한 효과도 없어야 합니다. 단순히 전역 변수를 사용하지 않는다는 사실만으로 이 모든 조건이 보장되지는 않습니다.
전역 변수는 커플링을 부추깁니다
유지보수하기 좋도록 느슨하게 결합한 아키텍처를 아직 충분히 파악하지 못한 신입에게 ‘돌멩이가 땅에 떨어질 때 소리를 내기’를 첫 작업으로 맡겼다고 해 보겠습니다. 기존 작업자는 물리 코드와 사운드 코드 사이에 커플링이 생기지 않도록 주의하지만, 신입은 일단 작업을 빨리 끝내고 싶습니다.
AudioPlayer 인스턴스에 전역으로 접근할 수 있다면 #include 한 줄만 추가해도 신중하게 만든 아키텍처를 훼손할 수 있습니다.
전역 인스턴스가 없었다면 #include를 추가해도 곧바로 소리를 낼 수는 없었을 것입니다. 쉽게 할 수 없다는 것은 두 모듈이 서로를 몰라야 하며 다른 방식으로 해결해야 한다는 신호입니다. 인스턴스 접근을 통제하면 커플링도 통제할 수 있습니다.
전역 변수는 동시성 프로그래밍과 잘 맞지 않습니다
싱글코어로 게임을 실행하던 시대는 오래전에 끝났습니다. 멀티스레딩을 최대한 활용하지 않더라도 적어도 그 방식에 맞게 코드를 만들어야 합니다.
무언가를 전역으로 만들면 모든 스레드가 보고 수정할 수 있는 메모리 영역이 생깁니다. 다른 스레드가 그 데이터에 어떤 작업을 하는지 모를 수도 있습니다. 교착상태나 경쟁 상태(race condition)처럼 찾기 어려운 동기화 버그가 생기기 쉽습니다.
전역 변수, 곧 싱글턴에서 생길 수 있는 문제가 얼마나 무서운지 충분히 전달되었을 것입니다. 그렇다면 전역 상태 없이 게임 아키텍처를 만들려면 어떻게 해야 할까요?
방법은 여럿 있고 이 책에서도 여러 방향으로 다루지만, 쉽지는 않습니다. 출시 일정에 쫓기면 싱글턴은 만병통치약처럼 보입니다. 객체지향 디자인 패턴 책에도 나왔으니 구조적으로 좋을 듯하고, 지금까지 익숙하게 작업하던 방식으로 소프트웨어를 설계할 수 있기 때문입니다.
하지만 싱글턴은 치료제보다 진정제에 가깝습니다. 전역 상태에서 생기는 문제를 살펴보면 싱글턴으로 해결되는 것이 하나도 없습니다. 싱글턴 자체가 클래스로 캡슐화한 전역 상태이기 때문입니다.
문제가 하나뿐인데도 두 문제를 풀려고 합니다
GoF 정의에서 ‘~하고’라는 연결에 주목해 보겠습니다. 싱글턴은 두 문제를 한 번에 해결하려는 것이 아닐까요? 둘 중 하나만 해결하고 싶으면 어떻게 해야 할까요?
인스턴스 하나를 강제하고 싶지만 전역 접근은 원하지 않을 수 있습니다. 반대로 전역 접근은 필요하지만 인스턴스는 여러 개여도 될 수 있습니다. ‘인스턴스 하나’와 ‘전역 접근’ 중 싱글턴을 선택하게 하는 이유는 보통 전역 접근입니다.
로깅 클래스를 생각해 보겠습니다. 게임의 여러 모듈에서 진단 정보를 로그로 남길 수 있으면 편리합니다. 하지만 모든 함수의 인수에 Log 인스턴스를 추가하면 메서드 시그니처가 복잡해지고 코드의 의도가 잘 보이지 않습니다.
가장 간단한 해결책은 Log를 싱글턴으로 만드는 것입니다. 그러면 모든 함수에서 직접 인스턴스를 얻을 수 있습니다. 다만 의도하지 않게 Log 객체를 하나만 만들 수 있다는 제한까지 생깁니다.
처음에는 문제가 되지 않습니다. 로그를 파일 하나에 쓰면 인스턴스도 하나면 충분합니다. 하지만 개발이 진행되고 팀원 모두가 필요한 정보를 남기면 로그 파일이 뒤죽박죽 쓰레기장이 됩니다. 정보 하나를 찾으려고 수십 페이지의 텍스트를 뒤져야 합니다.
로그를 여러 파일로 나누면 좋겠습니다. 네트워크·UI·오디오·게임플레이처럼 분야별 로거를 만들 수 있어야 합니다. 하지만 싱글턴인 Log는 인스턴스를 여러 개 만들 수 없습니다. 이 설계 제약은 다음과 같이 호출하는 모든 코드에 영향을 줍니다.
Log::instance().write("Some event.");
C++
복사
다시 인스턴스를 여러 개 만들 수 있게 하려면 클래스와 호출부를 모두 바꿔야 합니다. 어디에서나 편하게 접근한다는 장점이 거꾸로 단점이 되었습니다.
원주 7: Log가 여러 게임 프로젝트에서 함께 사용하는 라이브러리에 있으면 문제가 더 심각합니다. 설계를 바꾸려면 다른 팀도 코드를 고쳐야 하는데, 대부분 그럴 시간도 의지도 없을 것입니다.
편집기 미리보기 두 개를 떠올려 보세요.
전역 접근은 호출부를 짧게 만들지만 초기화·종료 순서와 테스트 대체를 어렵게 만들 수 있습니다. 같은 프로세스에서 게임 두 개나 편집기 미리보기 두 개를 실행하려고 하면 ‘한 개만 존재한다’는 제한이 드러납니다. 객체가 하나여야 하는 이유와 접근 범위를 따로 설명할 수 있어야 합니다.
게으른 초기화는 시점을 제어할 수 없습니다
가상 메모리를 쓸 수 있고 성능 요구가 심하지 않은 데스크톱 PC에서 게으른 초기화는 괜찮은 기법입니다. 하지만 게임은 다릅니다. 시스템 초기화에는 메모리 할당과 리소스 로딩 등 할 일이 많아서 시간이 꽤 걸릴 수 있습니다.
오디오 시스템 초기화에 몇백 밀리초가 걸린다면 그 시점을 통제해야 합니다. 처음 소리를 재생할 때 초기화하게 두면 전투 중에 초기화가 시작되어 프레임이 떨어지고 게임이 버벅일 수 있습니다.
게임에서는 메모리 단편화를 막으려고 힙 메모리 할당 방식을 세밀하게 제어하는 경우가 많습니다. 오디오 초기화가 상당한 힙 메모리를 할당한다면, 힙의 어디에 메모리를 할당할지 통제할 수 있도록 적절한 초기화 시점을 찾아야 합니다.
원주 8: 메모리 단편화는 객체 풀 패턴(19장)에서 더 자세히 설명합니다.
이 두 문제 때문에 저자가 본 거의 모든 게임은 게으른 초기화를 쓰지 않았습니다. 대신 다음과 같이 구현했습니다.
class FileSystem {
public:
static FileSystem& instance() { return instance_; }
private:
FileSystem() {}
static FileSystem instance_;
};
C++
복사
게으른 초기화 문제는 해결하지만, 싱글턴이 단순한 전역 변수보다 나았던 점을 포기합니다. 정적 인스턴스에는 다형성을 사용할 수 없습니다. 클래스는 정적 객체 초기화 시점에 생성되며, 인스턴스가 필요 없어도 메모리를 해제할 수 없습니다.
싱글턴 대신 단순한 정적 클래스를 만든 셈입니다. 이것도 나쁘지는 않지만 정적 클래스만으로 충분하다면 instance()를 없애고 정적 함수를 쓰는 편이 낫습니다. Foo::bar()가 Foo::instance().bar()보다 간단하고, 정적 메모리에 접근한다는 사실도 분명하게 보여 줍니다.
원주 9: 비정적 클래스로 바꾸고 싶을 때 모든 호출부를 고쳐야 하는 정적 클래스보다 싱글턴이 낫다는 주장도 있습니다. 이론적으로는 인스턴스를 받아 일반 메서드처럼 호출할 수 있기 때문입니다. 옮긴이는 Foo& foo = Foo::instance();로 참조를 받은 뒤 foo.bar();로 호출할 수 있다는 뜻이라고 설명합니다. 하지만 저자는 실제로 이렇게 쓰는 것을 본 적이 없으며, 모두 Foo::instance().bar()처럼 호출했다고 합니다. 이 상태에서 Foo를 비싱글턴으로 바꾸려면 결국 모든 호출부를 수정해야 하므로, 차라리 단순한 정적 클래스가 낫다고 봅니다.
6.4 대안(Alternatives)
저자의 의도가 전달되었다면 싱글턴을 쓰기 전에 다시 생각하게 될 것입니다. 그렇다고 싱글턴으로 해결하려던 문제가 사라지지는 않습니다. 어떤 문제를 풀려는지에 따라 대안이 몇 가지 있지만, 먼저 다음 질문부터 생각해 보겠습니다.
클래스가 꼭 필요한가요?
저자가 게임 코드에서 본 싱글턴 가운데는 다른 객체를 관리하기 위해 애매하게 존재하는 ‘관리자(manager)’가 많았습니다. Monster, MonsterManager, Particle, ParticleManager, Sound, SoundManager, ManagerManager까지, 모든 클래스에 관리자가 있는 것처럼 보이기도 했습니다. Manager 대신 System이나 Engine이라는 이름을 붙여도 본질은 같습니다.
관리 클래스가 필요할 때도 있지만, 객체지향 프로그래밍을 제대로 이해하지 못해 만드는 경우도 많습니다. 다음 두 클래스를 보겠습니다.
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);
}
};
C++
복사
예제가 다소 허술해 보여도 복잡한 부분을 제외하고 핵심 설계만 보면 이런 코드는 흔합니다. 얼핏 보면 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_;
};
C++
복사
관리자를 없애자 문제도 사라졌습니다. 서툰 싱글턴은 다른 클래스에 기능을 더하는 ‘도우미’인 경우가 많습니다. 가능하면 도우미의 동작 코드를 원래 클래스로 옮겨 보세요. 객체가 스스로를 챙기게 하는 것이 객체지향 프로그래밍입니다.
관리자 외에도 싱글턴으로 해결하던 문제가 남아 있으므로 다른 대안도 살펴보겠습니다.
원문 코드의 작은 오기
제공 PDF 115페이지의 마지막 멤버 선언은 inr y_;로 인쇄되어 있습니다. 위에서는 자료형을 int y_;로 바로잡았습니다. 관리자 제거 전후의 클래스·함수·조건식은 모두 유지했습니다.
전역 접근 없이 인스턴스 하나만 보장하기
싱글턴이 해결하는 첫 번째 문제입니다. 파일 시스템 예제에서는 인스턴스가 하나임을 보장하는 일이 중요합니다. 그렇다고 누구나 어디에서나 접근하게 만들고 싶은 것은 아닐 수 있습니다.
특정 코드만 접근하게 하거나 다른 클래스의 private 멤버로 넣고 싶을 수도 있습니다. 이때 누구나 접근하는 전역 객체를 제공하면 구조가 약해집니다.
원주 10: 파일 시스템 래퍼를 클래스나 네임스페이스 같은 추상 계층으로 한 번 더 감쌀 수도 있습니다.
전역 접근 없이 인스턴스 하나를 보장하는 방법은 여러 가지입니다. 다음은 그중 하나입니다.
class FileSystem {
public:
FileSystem() {
assert(!instantiated_);
instantiated_ = true;
}
~FileSystem() {
instantiated_ = false;
}
private:
static bool instantiated_;
};
bool FileSystem::instantiated_ = false;
C++
복사
어디에서든 인스턴스를 만들 수 있지만 두 개 이상이 되는 순간 단언문(assert)에 걸립니다. 적절한 곳에서 먼저 객체를 만들면, 다른 곳에서 이 인스턴스를 추가로 만들거나 임의로 접근하는 일을 막을 수 있습니다. 단일 인스턴스는 보장하되 클래스를 어떻게 사용할지까지 강제하지 않습니다.
다만 원문은 싱글턴이 클래스 문법으로 컴파일 시점에 단일 인스턴스를 보장하는 데 비해, 이 방식은 런타임에 개수를 확인한다는 점을 단점으로 꼽습니다.
원주 11: 단언문은 코드에 제약을 넣을 때 사용합니다. assert()는 인수를 평가해 참이면 아무 일도 하지 않고, 거짓이면 그 자리에서 코드를 중지합니다. 디버그 빌드라면 디버거를 띄우거나 적어도 어느 파일의 어느 줄에서 실패했는지 보여 줍니다.
assert()는 ‘이 값은 항상 참이어야 한다. 거짓이면 버그이므로 바로 멈추고 고쳐야 한다’고 단언합니다. 함수가 인수는 NULL이 아니어야 한다고 단언하면, 함수는 널을 인수로 받지 않는다고 호출자와 계약한 것입니다.
단언문은 코드가 예상과 다르게 작동할 때 바로 버그를 발견하게 하여, 나중에 이상한 형태의 오류가 사용자에게 나타나는 일을 막습니다. 버그가 자신을 만든 코드에서 달아나지 못하도록 가두는 울타리 역할을 합니다.
단언문은 설명용 제약을 보여 줍니다.
위 예제에서 전역인 것은 접근 가능한 객체 자체가 아니라 ‘생성했는가’를 나타내는 플래그입니다. 객체의 소유자가 다른 코드에 참조를 줄지 결정할 수 있습니다. 실제 제품 코드에서는 단언문이 비활성화되는 빌드, 복사·이동, 여러 스레드의 동시 생성도 별도로 고려해야 합니다.
인스턴스에 쉽게 접근하기
쉬운 접근성은 싱글턴을 고르는 가장 큰 이유입니다. 여러 곳에서 필요한 객체에 쉽게 접근할 수 있지만, 원하지 않는 곳에서도 쉽게 접근할 수 있다는 비용이 따릅니다.
변수는 작업이 가능한 범위 안에서 최대한 적게 노출하는 편이 좋습니다. 노출 범위가 작을수록 코드를 읽으며 머릿속에 담아야 할 범위도 줄어듭니다. 객체를 전역 싱글턴으로 바꾸느라 코드를 산탄총으로 쏜 것처럼 들쑤시기 전에 다른 접근 방법도 고민해 보겠습니다.
필요한 곳으로 넘겨주기
객체를 필요로 하는 함수에 인수로 전달하는 방법이 가장 쉽고 최선인 경우가 많습니다. 번거롭다는 이유로 무조건 외면하지 말고 한 번 생각해 볼 만합니다.
객체를 렌더링하는 함수를 생각해 보겠습니다. 렌더링 상태를 담은 그래픽 디바이스 대표 객체에 접근할 수 있어야 합니다. 따라서 일반적으로 모든 렌더링 함수가 context 같은 매개변수를 받습니다.
하지만 어떤 객체는 메서드 시그니처에 어울리지 않습니다. AI 함수에서도 로그를 남길 수 있어야 하지만, AI의 핵심이 아닌 Log를 인수로 추가하는 것은 조금 어색합니다. 다른 방법을 찾아보겠습니다.
원주 12: 이를 의존성 주입(dependency injection)이라고 부르기도 합니다. 필요한 객체를 전역 등에서 직접 찾지 않고 매개변수로 받아 사용합니다. ‘의존성 주입’이라는 말을 의존 객체를 제공하는 좀 더 복잡한 방식으로 이해하는 사람도 있습니다.
원주 13: 로그처럼 코드 곳곳에서 나타나는 것을 횡단 관심사(cross-cutting concern)라고 합니다. 이를 깔끔하게 다루는 방법은 구조를 설계할 때 계속 고민해야 하며, 정적 타입 언어에서 특히 그렇습니다. 이 문제를 해결하려고 등장한 것이 관점지향 프로그래밍(aspect-oriented programming)입니다.
상위 클래스에서 얻기
많은 게임은 클래스 대부분이 한 단계만 상속하도록 상속 구조를 얕고 넓게 가져갑니다. 몬스터 등 게임 객체가 상속하는 GameObject라는 상위 클래스가 있다고 해 보겠습니다.
이 구조에서는 게임 코드 대부분이 ‘잎(leaf)’ 또는 단말인 하위 클래스에 있습니다. 여러 클래스가 같은 상위 클래스인 GameObject에 접근할 수 있다는 점을 활용합니다.
class GameObject {
protected:
Log& getLog() { return log_; }
private:
static Log& log_;
};
class Enemy : public GameObject {
void doSomething() {
getLog().write("I can log!");
}
};
C++
복사
이제 GameObject를 상속하는 코드에서만 getLog()로 로그 객체에 접근할 수 있습니다. 상위 클래스의 protected 메서드를 활용해 하위 클래스를 구현하는 방식은 하위 클래스 샌드박스 패턴(12장)에서 다룹니다.
원주 14: 그러면 GameObject는 Log를 어디에서 얻을까요? 직접 만들어 정적 인스턴스로 보유하는 것이 가장 쉽습니다. GameObject가 적극적인 역할을 맡는 것이 싫다면 Log를 받는 초기화 함수를 제공하거나 서비스 중개자 패턴(16장)을 사용할 수도 있습니다.
이미 전역인 객체에서 얻기
모든 전역 상태를 없앤다는 것은 지나치게 이상적입니다. 결국 Game이나 World처럼 게임 전체 상태를 관리하는 전역 객체와 결합되기 마련입니다.
기존 전역 객체에 기대면 전역 클래스의 수를 줄일 수 있습니다. Log, FileSystem, AudioPlayer를 각각 싱글턴으로 만드는 대신 다음과 같이 합니다.
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_;
};
C++
복사
이제 전역에서 접근할 수 있는 클래스는 Game 하나입니다. 다른 시스템에는 다음과 같이 접근합니다.
Game::instance().getAudioPlayer().play(VERY_LOUD_BANG);
C++
복사
나중에 스트리밍이나 테스트를 위해 Game을 여러 개 지원하도록 바꾸더라도 Log, FileSystem, AudioPlayer에는 영향이 없습니다. 대신 더 많은 코드가 Game에 결합된다는 단점이 있습니다. 소리만 내고 싶어도 AudioPlayer를 얻기 위해 Game을 알아야 합니다.
여러 방법을 조합하면 이 문제를 해결할 수 있습니다. 이미 Game을 아는 코드는 거기에서 AudioPlayer를 받아 사용합니다. Game을 모르는 코드에는 인수로 전달하거나 상위 클래스를 통해 제공합니다.
원주 15: 데메테르 원칙에 어긋난다고 말하는 순수주의자가 있을 수 있지만, 저자는 싱글턴이 많은 것보다 낫다고 생각합니다.
서비스 중개자에서 얻기
지금까지는 전역 클래스가 Game처럼 일반적인 구체 클래스라고 가정했습니다. 하지만 여러 객체에 전역 접근을 제공하는 용도로만 사용하는 클래스를 별도로 정의할 수도 있습니다. 서비스 중개자 패턴(16장)에서 이 방법을 살펴봅니다.
테스트마다 다른 오디오 구현을 쓰려면?
전역 인스턴스의 상태가 테스트 사이에 남고 교체 시점이 숨겨지면 테스트를 분리하기 어렵습니다. 필요한 곳에 의존 객체를 명시적으로 전달하면 수명과 대체 구현을 제어하기가 쉬워집니다. ‘모든 곳에 같은 오디오를 제공한다’와 ‘각 테스트가 오디오를 선택한다’를 별개의 요구로 적어 보세요.
6.5 싱글턴에 남은 것(What Remains for Singleton?)
그러면 싱글턴이 정말 필요한 때는 언제일까요? 저자는 GoF가 제안한 싱글턴을 게임에 그대로 적용한 적이 한 번도 없다고 말합니다. 인스턴스를 하나로 제한하고 싶을 때는 정적 클래스를 쓰거나, 생성자에 정적 플래그를 두어 런타임에 개수를 검사했습니다.
이 책에는 싱글턴을 대체하는 다른 패턴도 나옵니다. 하위 클래스 샌드박스 패턴(12장)은 같은 클래스의 인스턴스들이 공용 상태를 전역으로 만들지 않고도 접근하게 합니다. 서비스 중개자 패턴(16장)은 객체를 전역에서 접근하게 하면서도 훨씬 유연하게 설정할 수 있게 합니다.
원문: 사용자 제공 한국어판 『게임 프로그래밍 패턴』, 로버트 나이스트롬 지음·박일 옮김, 6장. PDF 104~120페이지(인쇄 103~119쪽), PDF 121페이지는 빈 페이지입니다. 본문·코드·원주 1~15를 순서대로 옮겼으며, 회색
상자는 추가 설명입니다. 이 장에는 별도의 원본 도해가 없습니다.
상자는 추가 설명입니다. 이 장에는 별도의 원본 도해가 없습니다.


