소년코딩

싱글턴 패턴, Singleton Pattern

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

GoF의 싱글턴 패턴은 의도와는 달리 득보다는 실이 많다. GoF도 싱글턴 패턴을 남용하지 말라고 강조했지만, 개발자들 중에서 귀담아 듣는 이는 많지 않다.

다른 패턴들도 그렇지만, 싱글턴을 부적당한 곳에 사용하면 총상에 부목을 대는 것만큼이나 쓸모가 없다. 워낙 남용되는 패턴이다 보니 싱글턴을 피할 방법을 주로 다루겠지만, 그래도 우선은 싱글턴 패턴에 대해 살펴보자.


싱글턴 패턴

오직 한 개의 인스턴스만을 갖도록 보장

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

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

전역적인 접근점을 제공

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

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

        return *instance_;
    }

private:
    FileSystem() {}
    static FileSystem* 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. 싱글턴을 상속할 수 있다.

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

먼저 다음과 같이 상위 클래스를 만든다.

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

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

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

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

#if 같은 전처리기 지시문을 이용해서 간단하게 컴파일러가 시스템에 맞는 파일 시스템 객체를 만들게 할 수 있다. FileSystem::instance()를 통해서 파일 시스템에 접근하기 때문에, 플랫폼 전용 코드는 FileSystem 클래스 내부에 숨겨놓을 수 있다.


싱글턴이 왜 문제인가?

알고 보니 전역 변수

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

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

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

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

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

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

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


대안

클래스가 꼭 필요한가?

게임 코드의 싱글턴 클래스 중에는 애매하게 다른 객체 관리용으로만 존재하는 '관리자'가 많다. 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_;
};

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

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

앞서 본 파일 시스템 예제에서 클래스 인스턴스를 하나만 잇도록 보장하는 건 중요하다. 이럴 때 전역에서 누구나 접근할 수 있게 만들면 구조가 취약해진다.

전역 접근 없이 클래스 인스턴스만 한 개로 보장할 수 있는 방법이 몇 가지 있다.

class FileSystem {
public:
    FileSystem() {
        assert(!instantiated_);  // 단언문 - 인수 값이 참이면 아무것도 하지 않지만, 거짓이면 그 자리에서 코드를 중지한다.
        instantiated_ = true;
    }

    ~FileSystem() {
        instantiated_ = false;
    }

private:
    static bool instantiated_;
};

bool FileSystem::instantiated_ = false;

이 클래스는 어디서나 인스턴스를 생성할 수 있지만, 인스턴스가 둘 이상 되는 순간 단언문에 걸린다.

적당한 곳에서 객체를 먼저 만든다면 아무 곳에서나 이 인스턴스를 추가로 만들거나 접근하지 못하도록 보장할 수 있다. 단일 인스턴스는 보장하지만 클래스를 어떻게 사용할지에 대해서는 강제하지 않는다.

다만 싱글턴은 클래스 문법을 활용해 컴파일 시간에 단일 인스턴스를 보장하는 데 반해, 이 방식에서는 런타임에 인스턴스 개수를 확인한다는 게 단점이다.

인스턴스에 쉽게 접근하기

쉬운 접근성은 싱글턴을 선택하는 가장 큰 이유다. 싱글턴을 사용하면 여러 곳에서 사용해야 하는 객체에 쉽게 접근할 수 있다. 이런 편리함에는 원치 않는 곳에서도 쉽게 접근할 수 있다는 비용이 따른다.

1. 넘겨주기

객체를 필요로 하는 함수에 인수로 넘겨주는 게 가장 쉬우면서도 최선인 경우가 많다.(의존성 주입) 객체를 렌더링하는 함수를 생각해보자. 렌더링하려면 렌더링 상태를 담고 있는 그래픽 디바이스 대표 객체에 접근할 수 있어야 한다. 이럴 때는 일반적으로 모든 렌더링 함수에서 context같은 이름의 매개변수를 받는다.

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() 를 통해서 로그 객체에 접근할 수 있다.

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

전역 상태를 모두 제거하기란 너무 이상적이다;. 결국에는 Game이나 World같이 전체 게임 상태를 관리하는 전역 객체와 커플링되어 있기 마련이다. 기존 전역 객체에 빌붙으면 전역 클래스 개수를 줄일 수 있다. Log, FileSystem, AudioPlayer를 각각 싱글턴으로 만드는 대신 이렇게 해보자.

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(MUSIC_3);

이 포스트의 글과 그림의 출처는 http://gameprogrammingpatterns.com/singleton.html 입니다.


디자인패턴

by 소년코딩

추천은 글쓴이에게 큰 도움이 됩니다.

악플보다 무서운 무플, 댓글은 블로그 운영에 큰 힘이됩니다.

댓글 로드 중…

블로그 정보

소년코딩 - 소년코딩

소년코딩, 자바스크립트, C++, 물리, 게임 코딩 이야기

최근에 게시된 이야기