싱글턴 (Singleton)

 

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

 

어떤 식으로 써야하는지를 알아야 하는 다른 패턴과는 반대로 어떤 식으로 안써야하는지를 아는것이 더 중요한 패턴이다.

 

 

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

아무데서나 클래스 인스턴스 여러개를 만들 수 없어야 한다.

 

◾ 전역 접근점을 제공

하나의 인스턴스만 생성하는 것에 더해서 전역에서 접근할 수 있는 메서드를 제공한다.

 

class FileSyetem {
public:
    static FileSystem& instance() {
        if (instance_ == nullptr) { // 게으른 초기화
            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:
    static FileSystem& instance() {
    #if PLATFORM == PLAYSTATION3
        static FileSystem* instance = new PS3FileSystem();
    #elif PLATFORM == WII
        static FileSystem* instance = new WiiFileSystem();
        
        return *instance;        
    }
    virtual ~FileSystem() {}
    virtual char* readFile(char* path) = 0;
    virtual void writeFile(char* path, char* contents) = 0;
protected:
    FileSystem() {}
};

class PS3FileSystem : public FileSystem {
public:
    virtual char* readFile(char* path) { /* PS3 파일 시스템 사용 */ }
    virtual void writeFile(char* path, char* contents) { /* PS3 파일 시스템 사용 */ }
};

class WiiFileSystem : public FileSystem {
public:
    virtual char* readFile(char* path) { /* Wii 파일 시스템 사용 */ }
    virtual void writeFile(char* path, char* contents) { /* Wii 파일 시스템 사용 */ }
};

 

 

왜 문제인가?

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

순수 함수는 해당 함수의 코드와 매개변수만 확인하면 된다. 하지만 전역 변수나 함수에 접근한다면 해당 전역 변수와 함수에 접근하는 모든 곳을 다 살펴봐야만 상황을 파악할 수 있다.

 

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

 

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

모든 스레드가 볼 수 있기 때문에 일종의 공유자원이 되므로 교착상태 등의 스레드 동기화 버그가 발생할 수 있다.

 

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

아래와 같은 두 가지 문제가 있다고 하자.

  1. 인스턴스를 한개로 강제할 뿐, 전역 접근을 허용하지 않는다.
  2. 인스턴스는 여러개일 수 있지만, 전역 접근이 허용된다.

 

보통 2번의 경우에 싱글턴 패턴을 선택한다. 예를 들어 로그를 기록하는 클래스가 있다.

처음에는 별 문제가 되지 않다가 프로젝트의 규모가 커지면서 각자 필요한 정보를 로그로 남기다 보면 로그 파일이 뒤죽박죽 섞이게 된다.

이 시기쯤 되어서 로거를 나누려고 해도 싱글턴이기 때문에 인스턴스를 하나밖에 만들지 못하는 설계 제약이 발목을 잡는다.

 

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

지연 기법은 대체적으로 좋은 선택이지만 게임에서는 예외사항이 있다.

예를 들어 오디오 시스템의 초기화 시점이 최초로 소리가 재생될때라면 전투 도중에 초기화가 이루어지는 바람에 순간적으로 프레임이 떨어질 수 있다.

또한 오디오 시스템이 상당한 양의 메모리가 할당된다면 힙 어디에 메모리를 할당할지 제어할 수 있어야 하기 때문에 무조건 게으른 초기화가 이루어져선 안되고 적절한 초기화 시점을 찾아야 한다.

 

 

대안

싱글턴 클래스가 꼭 필요한지를 고려해봐야 한다.

애매하게 다른 객체 관리용으로만 존재하는 Manager, System, Engine 등의 관리자 클래스 인스턴스가 존재한다면 안에 존재하는 기능들을 원래 클래스에 구현해 두는것이 더 좋을 수도 있다.

객체가 스스로를 챙기는것이 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 {
public:
    void doSomething() {
        getLog().write("I can log!");
    }
};

 

파생 객체들이 공통된 단일 인스턴스를 사용해야 하는 경우라면 기본 클래스에 정적 데이터를 정의함으로써 인스턴스를 얻을 수 있다.

 

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

전역 상태를 모두 제거한다는 것은 사실상 매우 어렵다. 결국 전체 게임 상태를 관리하는 Game이나 World같은 전역 객체와 커플링 될수밖에 없기 때문이다.

그대신 어쩔수 없이 존재하는 전역 객체에 빌붙어서 전역 클래스 숫자를 줄일 수 있다.

 

class Game {
public:
    static Game& instance() { return instance_; }
    
    Log& getLog() { return *log_; }
    FileSystem& getFileSystem() { return *fileSystem_; }
    AudioPlayer& getAudioPlayer() { return *audioPlayer_; }
    ...
private:
    static Game instance_;
    Log *log_;
    FileSystem* fileSystem;
    AudioPlayer* audioPlayer;
};

 

기존에는 FileSystem, AudioPlayer 등의 클래스가 전역 객체로 개별적으로 존재해야 했지만, 기존에 존재하는 전역 클래스인 Game의 멤버로 들어감으로써 전역 클래스 개수를 줄인다.

그만큼 더 많은 코드가 Game 클래스와 커플링 된다는 단점은 존재한다.

 

 

추후 싱글턴 패턴을 대체할 수 있는 샌드박스 패턴, 서비스 중개자 패턴을 다룬다.

+ Recent posts