싱글턴 패턴, Singleton Pattern

오직 한 개의 클래스 인스턴스만을 갖도록 보장하고, 이에 대한 전역적인 접근점을 제공한다.

싱글턴 패턴은 의도와는 달리 득보다는 실이 많다. 
싱글턴 패턴을 남용하지 말라고 하지만, 개발자들 중에는 귀담아 듣는 사람이 많지 않다.
워낙 남용되는 패턴이다 보니 싱글턴을 피할 방법을 주로 다루겠지만, 그래도 우선은 싱글턴 패턴에 대해 살펴보자.


싱글턴 패턴

1. 오직 한 개의 인스턴스(객체)만을 갖도록 보장

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

 

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

 

2. 전역적인 접근점을 제공

로깅, 콘텐츠 로딩, 게임 저장 등 여러 내부 시스템에서 파일 시스템 참조 클래스를 사용할 것이다. 
이들 시스템에서 파일 시스템 클래스 인스턴스를 따로 생성할 수 없으므로 싱글턴 패턴은 하나의 인스턴스만 
생성하는 것 뿐만 아니라 그 인스턴스를 전역에서 접근할 수 있는 메서드를 제공한다.

 

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

public:
  static FileSystem& instance(){
    // 게으른 초기화
    if(instance_ NULL){
      instance_ = new FileSystem();
    }
    
    return *instance_;
  }
};

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

 

 

요즘은 아래처럼도 만든다.

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

c++ 11에서는 정적 지역 변수 초기화 코드가 멀티스레드 환경에서도 딱 한번 실행되어야 한다. 
즉, 최신 c++ 컴파일로 컴파일하면 이 코드는 이전 예제와는 달리 스레드에서 안전하다.


싱글턴을 사용하는 이유는?

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

 

2. 런타임에 초기화된다.

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

 

3. 싱글턴을 상속할 수 있다.

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

 

1. 먼저 아래와 같이 상위 클래스를 만든다.

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

 

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

class PS3FileSystem : public FileSystem{
public:
  virtual char* readFile(char* path){
    // SONY의 파일 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를 사용한다
  }
};

 

3. 이제 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(){}
};

 

4. 핵심!!
-> 인스턴스를 생성하는 부분!!

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 클래스 내부에
숨겨놓을 수 있다.

 


싱글턴이 왜 문자인가?

1. 알고 보니 전역 변수이다.

  전역 변수는 코드를 이해하기 어렵게 한다. 예를 들어 함수에 SomeClass::getSomeGlobalData() 같은 코드가 있다면
  전체 코드에서 SomeGlobalData에 접근하는 곳을 다 살펴봐야 상황을 파악할 수 있다.
  남이 만든 함수에서 버그를 찾아야 할 때, 함수가 전역 상태를 건드리지 않는다면
  함수 코드와 매개변수만 보면 된다.

 

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

  신입에게 '돌멩이가 땅에 떨어질 때 소리가 나게 하는' 작업을 첫 일감으로 줬다고 하자.
  기존 작업자들은 물리 코드와 사운드 코드 사이에 커플링이 생기는 걸 피하겠지만
  안타깝게도 경험이 부족한 신입은 AudioPlayer 인스턴스를 전역적으로 접근할 수 있다 보니,
  #include한 줄만 추가해도 신중하게 만들어놓은 아키텍처를 더럽힐 수 있다. 
  인스턴스에 대한 접근을 통제함으로써 커플링을 통제할 수 있다.

 

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

  다른 스레드가 전역 데이터에 무슨 작업을 하는지 모를 때 교착상태, 경쟁상태 등 정말 찾기 어려운 스레드
  동기화 버그가 생기기 쉽다.

 

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

  게으른 초기화는 괜찮은 기법이다. 그러나 게임은 다르다.
  시스템을 초기화할 때 메모리 할당, 리소스 로딩 등 할 일이 많다 보니 시간이 꽤 걸릴 수 있다.
  오디오 시스템 초기화에 몇백 밀리세컨드 이상 걸린다면 초기화 시점을 제어해야 한다.
  처음 소리를 재생할 때 게으른 초기화를 하게 만들면 전투 도중에 초기화가 시작되는 바람에
  화면 프레임이 떨어지고 버벅댈 수 있다.

 


대안

클래스가 꼭 필요한가?

게임 코드의 싱글턴 클래스 중에는 애매하게 다른 객체 관리용도로만 존재하는 관리자가 많다.
Monster, MonsterManager, Sound, SoundManager 등 모든 클래스에 관리자 클래스가 있는게 아닐까
싶을 정도이다. 

 

아래의 두 클래스를 보자.

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(Bullet& bullet){
    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);
  }
};

언뜻 보면 BulletManager를 싱글턴으로 만들어야겠다는 생각이 들 수 있다.
Bullet을 쓰려면 BulletManager도 필요할 테니 말이다. 그렇다면 관리자 클래스 인스턴스는 몇 개 필요할까?
정답은 0개다. 아래와 같이 만들면 관리자 클래스에 대한 '싱글턴' 문제를 해결할 수 있다.

 

class Bullet{
private:
  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_;
};

관리자 클래스를 없애고 나니 문제도 없어졌다. 서툴게 만든 싱글턴은 다른 클래스에 기능을 더해주는
도우미인 경우가 많다. 가능하다면 도우미 클래스에 있던 작동 코드를 모두 원래 클래스로 옮기자.

+ Recent posts