Game Development, 게임개발/디자인패턴

Singletone Pattern, 싱글톤 패턴 [디자인패턴]

게임이 더 좋아 2021. 10. 3. 15:30
반응형
728x170

 

아마도 디자인 패턴은 몰라도

그냥 싱글톤이라는 말은 어디서든 한 번쯤 들어봤을만한 단어가 아닌가

나도 디자인 패턴을 배우기 전에도 이미 뭐 싱글 뭐시기는 들어봤었다.

 

https://refactoring.guru/design-patterns/singleton

 

그럼 진짜로 알아보자.

 


 

싱글톤은 오직 한 개의 클래스 인스턴스만을 갖도록 보장해야하고

이에 대한 전역적인 접근을 통해 조작한다.

 

이 패턴은 편리해서 문제다.

즉, 남발해서 문제가 된다.

그래서 싱글톤을 피하거나 쓸 거면 제대로 써야하기에 배우는 것이다.

남발하게 된다면 열나는 애한테 이마에 붕대를 감아주는 꼴이나 다름이 없다.

 


 

싱글톤의 특징인

오직 한 개의 클래스 인스턴스만 가지게 보장한다라는 점이 있다.

인스턴스가 여러 개면 제대로 작동하지 않는 상황이 종종 생기는데

외부 시스템과 상호작용하면서 전역 상태를 관리하는 클래스가 그런 일이 많이 일어났다.

-> File System이 대표적이다.

 

파일 시스템 API를 래핑하는 클래스가 있다고 해보자.

파일 작업은 완료하는데 시간이 좀 걸리기 때문에 이 클래스는 비동기로 동작하게 만들어야 한다.

즉, 여러 작업이 동시에 진행될 수 있으므로 작업들을 서로 조율해야 한다.

한 쪽에서는 파일을 생성하고 다른 한쪽에서는 방금 생성한 파일을 삭제하려고 한다면,

래퍼 클래스가 두 작업을 파악해서 서로 간섭하지 않도록 해야 한다.

 

이를 위해서 파일 시스템 클래스로 들어온 호출이 이전 작업 전체에 대해서 접근할 수 있어야 한다.

아무 데서나 파일 시스템 클래스 인스턴스를 만들게 된다면

다른 인스턴스에서 어떤 작업이 진행 중인지 알 수가 없다.

때문에 이를 싱글톤으로 만들어서 클래스가 인스턴스를 하나만 가지도록 컴파일 단계에서 강제해서 해결한다.

 


 

위에서 말했다시피 파일 시스템을 사용하는 경우

로깅, 데이터 로딩, 데이터 저장 등 여러 곳에서 파일 시스템 래퍼클래스를 사용할 것이다.

또한 비동기적 동작을 한다면 서로 간섭을 하지 못하게 해야 한다.

 

하지만 시스템에서 파일 시스템 클래스 인스턴스를 생성할 수 없다면

파일 시스템에는 어떻게 접근할 것인가?

 

싱글턴은 위와 같은 문제에서도 해결방법을 제시하는데

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

그래서 누구든지 해당 인스턴스에 접근이 가능하다.

 

class FileSystem{
public:
	//해당 객체를 가리키는 포인터를 반환하는 메서드가 정적으로 선언
    static FileStstem& instance(){
        if(instance_ == NULL){
            instance_ = new FileSystem();
        }
        return *instance_;
private:
    FileSystem() {}
    static FileSystem* instance_;
};

 

다시 말해서 FileSystem의 클래스를 선언하고

해당 클래스의 인스턴스가 생성되어 있지 않다면

해당 인스턴스를 만들어서 해당 인스턴스를 가리키는 포인터를 반환한다.

이미 인스턴스가 있을 경우 해당 인스턴스가 가지고 있는 포인터를 반환한다.

 

instance_ 는 정적으로 선언된 멤버 변수로 클래스 인스턴스를 저장한다.

생성자는 private으로 되어있기 때문에 밖에서는 인스턴스가 생성이 불가능하다.

public에 있는 instance() 정적 메서드가 코드 어디에서나 싱글턴 인스턴스에 접근할 수 있게 하는 것이다.

 


 

싱글턴을 사용하는 이유는

파일 시스템 래퍼를 번거롭게 인수로 주고받지 않아도 어디에서나 접근할 수 있다는 점에서 충분해 보인다.

또한 한 번도 사용하지 않는다면 인스턴스를 생성하지 않으므로 

게으른 초기화를 통해 사용될 때만 초기화가 된다.

 

싱글톤으로 정적 멤버 변수로 초기화 하지만서도 위와 같이 구현한다면

게으른 초기화로 늦게 초기화가 되기 때문에는 클래스가 필요로하는 정보가 준비되어 있다.

 

또한 싱글톤은 상속도 가능하다.

파일 시스템 래퍼가 크로스 플랫폼을 지원해야 한다면

추상 인터페이스를 만든 뒤 플랫폼마다 구체 클래스를 만들면 된다.

 

예를 들면 아래와 같다.

//상위 클래스
class FileSystem{
public:
    virtual ~FileSystem(){} // 소멸자
    virtual char* readFile(char* path) = 0;
    virtual void writeFile(char* path, char* contents) = 0;
};

///하위 클래스

class PSFileSystem : public FileSystem{
public:
    virtual char* readFile(char* path){
        //Sony의 파일시스템 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 == PS
     static FileSystem *instace = new PSFileSystem();
#elif ....
#else ....

    return *instance;
}

 

 

 


 

 

지금까지는 싱글톤이 좋은 이유를 말했다.

앞서 장점만 보면 싱글톤을 왜 지양해야하는 것이지? 라는 의문이 들 것이다.

 

하지만 싱글톤으로 구성된 것들은 길게 보면 비용을 지불하게 된다.

그 비용이 생각보다 크면 싱글톤을 다르게 바꿔야 하는 것뿐이다.

 

전역 변수를 남발하지 말라는 소리를 들은 적이 있는가?

그렇다.

싱글톤을 두고 하는 말이다.

전역 변수는 코드를 이해하기 어렵게 만든다.

남이 만든 코드에서 버그를 찾아야할 때 함수가 전역 상태를 건드리지 않는다면

함수 코드와 매개변수만 보면된다.

하지만 전역 변수가 있다면.. 전체 코드를 다 봐야하는 불상사가 생길 수도 있다.

 

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

역시나 어디에나 쓰이기 때문에 무엇인가를 변경할 때 전역 변수를 고려한 변경이 되어야만 한다.

때문에 커플링이 생긴다.

인스턴스에 대한 접근을 통제함으로 커플링을 통제해야 한다.

 

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

다시 말해서 무엇인가 전역으로 선언한다면 모든 스레드가 보고 접근할 수 있는 메모리 영역이 생기는 셈이다.

때문에 다른 스레드가 작업 중임에도 그 스레드가 아닌 스레드에서는 어떤 작업이 진행 중인지 몰라서

교착상태, 경쟁 상태가 생기는 동기화 버그가 일어날 수 있다.

 

그렇다.

싱글톤은 가벼운 게임에서는 거의 만능이다시피 활용됐지만

게임이 복잡해질수록 거대해질수록 지양해야하는 것이 되었다.

써야한다면 적재적소에 써야만 한다.

 

그렇다면 싱글톤 패턴은 언제 쓰일까?

한 개의 인스턴스에 전역 접근점을 주어 사용하는데..?

보통 전역 접근이 유용해서 그 때문에 쓰곤 한다.

 


 

로깅 클래스를 생각해보면

게임 내 모듈에서 진단 정보를 로그로 남길 수 있게 하면 편할 것이다.

하지만 모든 함수 인수에 Log 클래스 인스턴스를 추가하면 메서드 시그니처가 번잡해지고 코드의 의도를 알아보기 어렵다.

가장 간단한 해결책은 Log 클래스를 싱글톤으로 구현하는 것이다.

그러면 모든 함수에서 직접 Log 클래스에 접근해 인스턴스를 얻을 수 있다.

다만 싱글톤으로 구현하면 인스턴스는 하나밖에 못만든다.

로그를 파일 하나에 다 쓴다면 인스턴스도 하나만 있는 것이 문제가 되지 않지만

개발팀 모두가 각자 필요한 정보를 로그로 남기면 로그 파일이 쓰레기장이 될 것이다.

 

즉, 로그를 여러 파일에 나눠쓸 수 있게하면 좋을 것이다.

그러려면 UI, 오디오, 게임플레이 등 분야별로 로거를 만들어야 한다.

하지만 Log가 싱글톤이다보니 인스턴스를 여러 개 만들 수 없다.

 

이러한 제약이 Log 클래스를 사용하는 모든 코드에 영향을 미치는 것이다.

다시 말해서 어디서나 편히 접근 가능한 싱글톤이 역으로 단점이 되어버린 것이다.

 


 

또한 게으른 초기화에 대해서 말해보자

분명 성능을 요하지 않는 프로그램에서는 괜찮은 기법으로 쓰인다.

하지만 게임에서는 다르다.

시스템을 초기화할 때 메모리 할당, 리소스 로딩 등 할 일이 많다보니 시간이 꽤 걸릴 수 있다.

오디오 시스템 초기화에 수백 ms가 걸린다면 초기화 시점을 제어해야 한다.

처음 소리를 재생할 때 초기화를 시키게 된다면 전투 도중에 초기화가 시작되어 게임이 버벅댈 수 있다.

마찬가지로 게임에서는 메모리 단편화를 막기 위해 힙에 메모리를 할당하는 방식을 세밀하게 제어하는데

오디오 시스템이 초기화될 때 상당히 많은 메모리를 할당해야 한다면

힙 어디에 메모리를 할당할 지 제어할 수 있도록 초기화 시점을 찾아야 한다.

 

위의 2가지 문제 때문에 게임에서는 게으른 초기화를 사용하지 않는다.

대신 싱글톤을 아래와 같이 구현했다.

 

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

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

 

위와 같이 구현할 경우

게으른 초기화 문제는 해결이 되지만

싱글톤이 그냥 전역변수보다 나은 점을 몇 가지 포기해야 한다.

우선 정적 인스턴스를 사용하면 다형성을 사용할 수 없다.

클래스는 정적 객체 초기화 시점에 생성된다.

인스턴스가 필요 없어도 메모리를 해제할 수 있다.

그니까 단순하게 싱글톤의 구현이 아닌 정적클래스를 구현한 것과 마찬가지다.

이것도 나쁘진 않지만 정적 클래스만으로 해결되었다면 instance()메서드를 제거하고

그냥 정적함수를 쓰는 것이 낫다.

 

 


 

그렇다면 싱글톤 안쓸게

그럼 대안은 있나??

 

 

쓰기 전에 코드를 보면서 우선 생각해보자

클래스가 꼭 필요한가? 필요한 클래스만 있는가?

 

아래 예제를 보자

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를 싱글톤으로 만들고 싶다고 생각할 수 있다.

과연 그럴까?

 

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

 

굳이 BulletManager가 필요없었다.

객체가 스스로 가질 것을 다 가지고 있는 것이 OOP의 본질이다.

관리자 클래스를 남발하지 말고 필요한 것은 해당 클래스가 다 가지게 하자.

 


 

싱글톤에서 하나의 클래스 인스턴스만 갖게하는 것은 파일시스템에서 중요한 문제 중 하나였다.

그렇다면 전역 접근 없이 클래스 인스턴스만 한 개로 보장할 수 있는 방법이 있을까?

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

bool FileSystem::instantiated_ = false;

 

이 클래스는 어디서 인스턴스를 생성할 순 있지만

인스턴스가 둘 이상 되는 순간 assert 에서 걸린다.

**assert는 코드에 제약을 넣을 때 사용된다.

값이 참이면 아무것도 하지 않지만 거짓이면 그자리에서 코드를 중지한다.

 

적당한 곳에서 객체를 먼저 만든다면 아무 곳에서나

이 인스턴스를 추가로 만들거나 접근하지 못하도록 보장할 수 있다.

단일 인스턴스는 보장하지만 클래스를 어떻게 사용할 지에 대해 강제하지 않는다.

 

다만 싱글톤은 클래스 문법을 활용해 컴파일 시간에 단일 인스턴스를 보장하는데 반해,

이 방법은 런타임에서 인스턴스 개수를 확인한다는 것이 단점이다.

 


 

인스턴스에 쉽게 접근하기 위해서는 어떻게 해야할까?

 

싱글톤은 원하는 곳에서도 편리하게 접근이 가능하지만 원치 않는 곳에서도 편리하게 접근 가능한 것이 문제다.

변수는 작업 가능한 선에서 최대한 적은 범위로 노출하는 것이 일반적으로 좋다.

변수가 노출된 범위가 적을수록 코드를 볼 때 머릿속에 담아둬야 할 범위가 줄어든다.

객체를 전역 접근 가능한 싱글톤 객체로 바꾸느라 밤 새기 전에

객체에 접근할 수 있는 다른 방법을 몇 개 고민해보자.

 


 

1. 넘겨주기

 

객체를 필요로 하는 함수에 인수로 넘겨주는 것이 가장 쉬우면서도 최선인 경우가 다수다.

객체를 렌더링하는 함수를 생각해보자

 

렌더링하려면 렌더링 상태를 담고 있는 그래픽 디바이스 대표 객체에 접근할 수 있어야 한다.

이럴 때는 일반적으로 모든 렌더링 함수에서 context 같은 이름의 매개변수를 받는다.

반면, 어떤 객체는 메서드 시그니처에 포함되지 않는다.

 

예를 들어 AI 관련 함수에서도 로그를 남길 수 있어야 하지만

AI 핵심이 아닌 Log 객체를 인수에 추가하기는 조금 그렇다.

 


 

2. 상위 클래스로부터 얻기

 

많은 게임에서 클래스를 대부분 한 단계만 상속할 정도로 상속구조를 얕고 넓게 가져간다.

몬스터나 다른 게임 내 객체가 상속받는 GameObject라는 상위 클래스가 있다고 해보자

이런 구조에서는 게임 코드의 많은 부분이 "단말"에 해당하는 클래스에 존재한다.

즉, 많은 클래스에서 같은 객체, GameObject 상위 클래스에 접근할 수 있다.

 

아래 예제를 보자

class GameObject{
protected:
    Log& getLog(){ return log_; }
    
private:
    static Log& log_;
};

class Enemy : public GameObject{
    void doSomething(){
        getLog().write("I can log");
    }
};

 

위와 같이 짜면 GameObject의 자식클래스에서만 getLog를 통해서 로그 객체에 접근할 수 있다.

상속받은 객체가 상위클래스로부터 받은 protected 메서드를 활용해

구현하는 방식은 하위 클래스 샌드박스 패턴이다.

 


 

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

 

전역 상태를 모두 제거하기란 너무 이상적이다.

결국에는 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 클래스 하나만 전역에서 접근하고 다른 시스템에 접근하려면

호출을 해야한다.

 

Game::instance().getAudioPlayer().play(VERY_LOUD_BANG);

 

나중에 Game 인스턴스를 여러 개 지원하도록 구조를 바꿔도

Log, FileSystem, AudioPlayer는 영향받지 않는다.

더 많은 코드가 Game 클래스에 커플링된다는 단점은 있다.

그저 사운드만 출력하고 싶어도 Game 클래스를 알아야 하는 단점이다.

 

이런 문제는 여러 방법을 조합해서 해결할 수 있다.

이미 Game 클래스를 알고 있는 코드에서는 AudioPlayer를 Game 클래스로부터 받아서 쓰면 된다.

Game 클래스를 모르는 코드에서는 앞서 본 것처럼 넘겨주거나

상위클래스로부터 얻기를 통해 AudioPlayer에게 접근하면 된다.

 


 

4. 서비스 중개자로부터  얻기

 

이를 서비스 중개자 패턴을 이용한 것이다.

 

 


 

아니 싱글톤의 장점도 말해주고 그것보다 단점이 크다 그러면 

안 쓰는 것 아냐?? 왜 배워야하지??

싱글톤은 그 개념의 발상과 싱글톤의 장점을 유지한 채 다른 방법을 알아내는 것이 공부라고 생각한다.

한 번 다시 읽어보면서 곱씹어보자.

 

반응형
그리드형