디자인패턴

싱글턴 패턴 (Singleton Pattern)

띠애모 2024. 3. 22. 19:26

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

 

싱글턴 사용 이유 

 

1. 한번도 사용하지 않는다면 아예 인스턴스를 생성하지 않아 메모리와 cpu 용량을 줄인다.

2. 런타임에 초기화된다 싱글턴은 최대한 늦게 초기화 되기 때문에 활용 가능한 점이 많다.

 

3. 오직 한 개의 클래스 인스턴스만 갖도록 보장 하기 때문에 아무데서나 클래스 인스턴스 여러개를 만들 수 없다. 이를 통해 게임 내에 하나만 존재해야하는 곳에 사용된다.

 

4. 게임 시스템에서 전체를 관장하는 스크립트(단일 시스템 자원 관리 차원)

 

5. 게임 시스템상 전역 변수의 역할을 하는 스크립트이다.

 

언리얼 엔진에서 제공하는 싱글톤 클래스는 다음과같다.

  • GameInstance
  • AssetManager
  • GameMode
  • GameState

 

현 스테이지의 몬스터 수를 전역으로 GameState에서 관리했다 몬스터를 모두 처치 할 시 다음 스테이지로

넘어가는 포탈이 생성되게끔 하였다.

 

또한 만들던 게임에서 캐릭터의 능력치가 다음 레벨이 오픈될때도 그대로 보존하고 싶었다

이 고민을 레벨이 전환되더라도 유지되는 GameInstance 특성이 싱글톤인 것을 활용하여 캐릭터의 능력치를 관리하였다.

 

6. 씬 로드시 데이터가 파괴되지 않고 유지된다 -> (언리얼의 GameInstance)

 

7. 여러 오브젝트가 접근을 해야 하는 스크립트의 역할

 

필자의 경우에 게임을 만들 당시에 하던 고민중에

현 스테이지에 몬스터를 모두 처치하면 다음 스테이지로 가는 포탈이 생성되게끔 하고 싶었는데

옵저버 패턴과 싱글톤 패턴을 이용하여 구현 한 적이 있다.

먼저 필드 내에 몬스터가 죽었는지 관찰하는 관찰자를 두었는데 이 관찰자는 

GameState에서 전역으로 관리되는 관리되는 몬스터 수를 참조했다

 

싱글턴은 상속 할 수 있다.

 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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 파일 시스템 사용 */ }
 
cs

 

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

 

싱글톤 패턴의 문제점.

싱글톤은 결국 전역변수기 때문에 코드를 이해하기 어렵게 한다. 

또한 클래스간의 커플링을 조장한다. 인스턴스에 대한 접근을 통제함으로써 커플링을 통제 할 수 있다.

 

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

요즘은 싱글코어가 아니라 멀티스레딩 방식에 맞게 게임을 설계해야한다.

만약 게임 내에 1개만 존재해야하는 클래스가 있는데 다른 스레드들에 의해 여러개 생성되었다면?

게임 전체가 교착상태에 빠질 수도 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Singleton {
 
    private static Singleton GameManager;
 
    
 
    private Singleton(){}
 
    
 
    public static Singleton getInstance(){
 
        if (GameManager== null){
 
            GameManager = new Singleton();
 
        }
 
        return GameManager;
 
    }
 
 }
 
cs

다음과 같은 코드를 예시로

2개의 스레드가 존재한다고 가정하고

1번째 스레드가 조건문을 통화하고 2번 스레드에게 제어권을 넘기고 

2번째 스레드도 조건문을 통화하고 1번째 스레드에게 제어권을 넘기면

결국 2개의 GameManager가 만들어지게 된다.

 

또한 한 개의 인스턴스에 많은 접근이 이뤄지면 알기 어려운 많은 버그가 생기기 마련이다.

싱글턴 패턴을 꼭 활용해야 한다면 동기화까지 신경써야 할 것이다.

 

게임 내에 싱글턴 패턴을 꼭 사용해야 할까? 물론 사용해야 할 상황이 생기기 마련이지만

편의성만을 위해서 싱글턴 패턴을 남용하는것은 코드가 복잡해지고 알기 힘든 버그가 생기기 마련이기 때문에

좋지 않은 방법이라고 생각한다.

 

싱글턴 패턴의 대안

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class FileSystem {
public:
    FileSystem() {
        assert(!instantiated_); // 단언문
        instantiated_ = true;
    }
    ~FileSystem() {
        instantiated_ = false;
    }
private:
    static bool instantiated_;
};
 
bool FileSystem::instantiated_ = false;
 
cs

인스턴스가 한개만 존재하길 바라면서 전역 접근을 허용하고 싶지 않은 경우에는 싱글턴 대신 생성자에 단언문을 넣어서 제어할 수 있다.

 

인스턴스가 최초 생성될때는 문제가 없지만 두 개째 생성부터는 단언문에 의해 코드 실행이 중지된다.
단일 인스턴스는 보장하면서 클래스를 어떻게 사용할지에 대해서는 제약이 없다.
다만 기존 싱글턴이 컴파일 타임에 단일 인스턴스를 보장하는 반면, 위의 방식은 런타임에 인스턴스 개수를 확인한다.

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class GameObject {
protected:
    Log& getLog() { return log_; }
private:
    static Log& log_;
};
 
class Enemy : public GameObject {
public:
    void doSomething() {
        getLog().write("I can log!");
    }
};
 
cs

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

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;
};
 
cs

 

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

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

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

 

기존에는 FileSystem, AudioPlayer 등의 클래스가 전역 객체로 개별적으로 존재해야 했지만, 기존에 존재하는 전역 클래스인 Game의 멤버로 들어감으로써 전역 클래스 개수를 줄인다.
그만큼 더 많은 코드가 Game 클래스와 커플링 된다는 단점은 존재한다.

 

싱글턴 패턴을 대체할 수 있는 방법 중 샌드박스 패턴, 서비스 중개자 패턴을 사용하는 방법도 있다.