Singleton Pattern(싱글턴 패턴)

2024. 6. 10. 02:43Game Develop/Design Pattern

싱글턴 패턴은 GoF의 세가지 패턴 중 생성 패턴에 속한다.

 

생성 패턴은 클래스나 객체의 생성과 참조 과정을 정의하는 패턴이다.

 

싱글턴 패턴의 특징은 다음과 같다.

- 하나의 객체를 생성하면 생성된 객체를 어디서든 참조할 수 있지만, 여러 프로세스가 동시에 참조하지는 못함

- 클래스 내에서 인스턴스가 하나뿐임을 보장하며, 불필요한 메모리 낭비를 최소화 할 수 있음

 

 

싱글턴 패턴은 의도와는 달리 득보다는 실이 많다. 싱글턴 패턴을 부적당한 곳에 사용하면 못질하는데 톱을 가져오는 것 만큼이나 쓸모가 없다.

 

싱글턴 패턴이 무엇인지 다루고, 또 피할 수 있는 방법도 같이 다뤄보자.

 

싱글턴 패턴은 오직 한 개의 클래스 인스턴스만 갖도록 보장한다.

인스턴스가 여러 개면 제대로 작동하지 않는 상황이 종종 있다. 예를들어 한쪽에서는 파일을 생성하고 다른 한쪽에서는 방금 생성한 파일을 삭제하려고 한다면, 서로 간섭하지 못하도록 해야한다. 하지만 아무데서나 파일 시스템 클래스 인스턴스를 만들 수 있다면 다른 인스턴스에서 어떤 작업을 진행 중인지를 알 방법이 없다. 이를 싱글턴으로 만들면 클래스가 인스턴스를 하나만 가지도록 컴파일 단계에서 강제할 수 있다.

 

또한 싱글턴 패턴은 전역 접근점을 제공한다. 하나의 인스턴스만 생성하는 것에 더해서, 그 인스턴스를 전역에서 접근할 수 있는 메서드를 제공한다. 이를 통해 누구든지 어디서든지 인스턴스에 접근할 수 있다.

 

간단한 코드로 알아보자.

class FileSystem{
public:
    static FileSystem& instance(){ // 코드 어디에서나 싱글턴 인스턴스에 접근할 수 있게 함
        // 게으른 초기화 (실제로 필요할 때까지 인스턴스 초기화를 미루는 역할)
        if(instance_ == NULL){
            instance_ = new FileSystem();
        }
        return *instance_;
    }
    
private: // 밖에서는 생성할 수 없음
    FileSystem();
    static FileSystem* instance_; // 클래스 인스턴스 저장
};

 

스레드 안전을 위해 이렇게도 만든다

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

 

싱글턴을 사용하는 이유를 종합해서 정리해보면

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

   : 싱글턴은 처음 사용될 때 초기화되므로, 게임 내에서 전혀 사용되지 않는다면 아예 초기화 되지 않는다

 - 런타임에 초기화 된다

    : 싱글턴 대안으로 정적 멤버 변수를 많이 사용하지만 정적 멤버 변수는 자동 초기화 되는 문제가 있다.

      이러면 프로그램이 실행된 다음에야 알 수 있는 정보를 활용할 수 없다

 - 싱글턴을 상속할 수 있다

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

      된다. 코드로 확인해보자

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

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

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

class WiiFileSystem : public FileSystem{
public:
    virtual char* readFile(char* path){
        // 닌텐도의 파일 IO API를 사용한다.
    }
    virtual char* 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
    ststaic FileSystem *instance = new PS3FileSystem();
#elif PLATFORM == WII
    stsatic FileSystem *instance = new WiiFileSystem();
#endif
    return *instance;
}

#if 같은 전처리기 지시문을 이용해ㅑ서 간단하게 시스템 객체를 만들게 할 수 있다.

 

 

자 위에 글처럼 짧게 놓고 보면 싱글턴 패턴에 큰 문제가 없다. 하지만 기게 놓고 보면 비용을 지불하게 된다.

 

싱글턴 패턴을 사용하면 생기는 문제는 크게 3가지가 있다.

 - 싱글턴 패턴은 클래스로 캡슐화된 전역 상태이기 때문

 - 어디서나 편하게 접근할 수 있다는 장점이 단점이 되어버릴 수 있기 때문

 - 정적 클래스만으로 다 해결할 수 있다면 굳이 쓸 필요가 없기 때문

 

 각 문제 별로 간단하게 알아보자

 

    싱글턴 패턴은 클래스로 캡슐화된 전역 상태

 

 예전에는 전연 변수와 정적 변수를 무분별하게 써도 크게 문제가 없었지만, 게임이 점차 커지고 복잡해짐에 따라 설계와 유지보수성이 요구 되기 시작했다. 따라서 하드웨어 한계보다는 생산성 한계 때문에 게임 출시가 늦어지게 되었다.

그런데 생산성을 낮추는 문제 중 하나가 전역 변수라는 것이다.

왜 전역 변수가 문제라는 것일까?

 

함수에 SomeClass::getSomeGlobalData() 같은 코드가 있다면 전체 코드에서 SomeGlobalData에 접근하는 모든 곳을 살펴봐야 상황을 파악할 수 있다. 이는 당연히 코드의 유지 보수 시간이 늘어날 것이다.

또한 전역 변수로 선언은 커플링을 조장한다. 경험있는 개발자들은 물리 코드와 사운드 코드 사이에 커플링이 생기는 피할것이다. 하지만 만약 AudioPlayer 인스턴스가 전역으로 선언 되어있다면 초보 개발자는 #include 한 줄만 추가해도 떨어지는 돌이 바닥에 닿으면 소리가 나게 하는 작업을 간단히 끝마칠 수 있는 것이다. 대신 신중히 만들어 놓은 아키텍쳐는 상당히 더럽혀질 것이다.

마지막으로 전역 변수는 멀티스레딩 같은 동시성 프로그래밍에 알맞지 않다는 것이다. 무엇인가가 전역으로 만들어지게 되면 모든 스레드가 참조할 수 있는 메모리 영역이 생기고, 여러 스레드가 그 전역 데이터를 사용하다 보면 교착상태, 경쟁 상태 등 정말 찾기 힘든 스레드 동기화 버그가 발생하기 쉽다.

 

 

어디서나 편하게 접근할 수 있다는 장점이 단점이 돼버릴 가능성

 

 싱글턴 패턴을 사용하는 요인 두 가지(하나의 인스턴스, 전역 접근) 중 보통 전역 접근이 싱글턴 패턴을 선택하는 이유이다.

게임 내의 진단 정보를 로그로 남길 수 있다면 상당히 편할 것이다. 모든 함수 인수에 log 클래스 인스턴스(Log::)를 추가하면 번거롭고 깔끔하지 않으니 Log 클래스를 싱글턴으로 만들어 보자. 로그를 파일 하나에 다 쓴다면 인스턴스도 하나만 있으면 되니 문제가 없고 사용하기 편리하다. 하지만 원하는 정보 하나를 찾아야 한다면 이야기가 달라진다. 모든 분야 별로 기록한 로그가 하나의 인스턴스에 저장되어 있고 이를 찾으려면 모든 로그 정보를 뒤져야한다. 따라서 이를 해결하려면 로그를 여러 파일에 나눠 쓸 수 있게 해야하는데 Log클래스가 싱글턴이다 보니 인스턴스 여러개를 만들 수 없다. 이렇게 되면 Log 클래스와 이 클래스를 사용하는 코드를 전부 수정해야 한다. 어디서나 편하게 접근할 수 있다는 장점이 단점이 돼버린 순간이다.

 

 

정적 클래스만으로 다 해결할 수 있다면 굳이 쓸 필요가 없음

 

 게으른 초기화를 하면 인스턴스가 생성될 때 초기화 되므로 메모리를 아낄 수 있다고 했었다. 하지만 게으른 초기화를 하게 만들면 전투 도중에 초기화가 시작되는 바람에 화면 프레임이 떨어지고 게임이 버벅대는 상황이 생길 수 있다. 이를 해결 하려면 힙 어디에 메모리를 할당할지를 제어할 수 있도록 적절한 초기화 시점을 찾아야한다.

 

그렇다면 이런식으로 게으른 초기화를 사용하지 않으면 되는거 아닌가?

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

private:
    FileSystem() {}
    
    static FileSytem instance_;    
};

 

 

이러면 게으른 초기화를 해결할 수 있지만, 싱글턴의 장점을 몇 개 포기해야 한다. 정적 인스턴스를 사용하면 다형성을 사용할 수 없다. 인스턴스가 필요 없어도 메모리를 해결할 수 없다는 뜻이다.

 

 또 싱글턴 대신 단순한 정적 클래스 하나 만든 셈인데, 정적 클래스만으로도 다 해결할 수 있다면 차라리 정적 함수를 사용하는 게 낫다. Foo::bar () 가 Foo::Instance().bar() 보다 간단하고. 명확하다.

 

 

위의 이유 때문에 싱글턴을 남용하는 행위는 상당히 위험하다. 싱글턴을 안쓴다면 어떤 대안이 있을까?

그 전에 앞서 싱글턴을 남용하는 대표적인 예부터 들어보겠다.

 

싱글턴 클래스 중에는 유독 객체 관리용으로 만든 Manager가 많다.

다음 두 클래스를 봐보자.

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);
    }
};

 

위의 코드를 언뜻 보면 BulletManager를 싱글터능로 만들어야겠다는 생각이 들 수 있따.

하지만 Bullet클래스의 핵심을 간파하면 굳이 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_;
     int y_;
 };

 

관리자 클래스를 없애면 비로소 총알이 총알다워 진다. 서툴게 만든 싱글턴은 다른 클래스에 기능을 더해주는 '도우미'인 경우가 많다. 객체가 스스로를 챙기게 하는 게 바로 OOP다.

 

 

 

자 이제 싱글턴 패턴의 대안에 대해 알아보자.

 

클래스 인스턴스를 하나만 있도록 보장하는 건 중요하다.

감사하게도 전역 접근 없이 클래스 인스턴스를 한개로 보장할 수 있는 방법이 몇 가지 있다.

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

bool FileSystem::instantiated_ = false;

 

 

적당한 곳에서 객체를 먼저 만든다면 아무 곳에서나 이 인스턴스를 추가로 만들거나 접근하지 못하도록 보장할 수 있다.

다시 말하면 단일 인스턴스는 보장하되 클래스를 어떻게 사용할지는 강제하지 않는다.

 

다만 이 방식은 싱글턴 패턴보다 런타임에 인스턴스 개수를 확인하기 때문에 조금 오래걸린다.

 

 

쉬운 운 접근성은 싱글턴 패턴의 가장 큰 이유다. 싱글턴을 사용하면 여러 곳에서 사용해야 하는 객체에 쉽게 접근할 수 있다. 변수는 작업 가능한 선에서 최대한 적은 범위로 노출하는 게 일반적으로 좋다.

 

따라서 객체를 필요로 하는 함수에 인수로 넘겨주는 게 가장 쉬우면서도 최선인 경우가 많다. 이 방법이 석연치 않다면

상위 클래스로부터도 얻을 수 있다.

 

에를들어

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같이 전체 게임 상태를 관리하는 전역 객체들과 어느정도 커플링 되어 있기 마련이다.

따라서 이런 전역 객체에 메서드를 만들어 주면 전역 클래스를 줄일 수 있다.

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::instance().getQudioPlayer().play(VERY_LOUD_BANG);

 

 

이렇게 하면 전역 객체를 줄일 수는 있겠지만 더 많은코드가 Game 클래스에 커플링 된다는 단점은 있다.