Home [게임 프로그래밍 패턴] Design Patterns Revisited: Singleton
Post
Cancel

[게임 프로그래밍 패턴] Design Patterns Revisited: Singleton

Singleton

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

  • 저자는 이번 장에서는 어떻게 하면 이 패턴을 안 쓸 수 있을지를 설명한다.

  • 이 패턴은 단점이 더 많다.

인스턴스에 접근하는데 싱글턴 즉, 전역 변수는 쉬운 해결책이다.

The Singleton Design Pattern

Restricting a class to one instance

  • 인스턴스가 여러 개면 제대로 작동하지 않는 상황이 종종 있다.

    • 외부 시스템과 상호작용하면서, 전역 상태를 관리하는 클래스 같은 게 그렇다.
  • 싱글턴으로 만들면, 클래스가 인스턴스를 하나만 가지도록 컴파일 단계에서 강제할 수 있다.

Providing a global point of access

  • 로깅, 콘텐츠 로깅, 게임 저장 등 여러 내부 시스템에서 파일 시스템 래퍼 클래스를 사용한다.
    • 파일 시스템 클래스 인스턴스를 따로 생성할 수 없으면, 싱글턴을 사용하여 해결할 수 있다,
  • 누구든지, 어디서든 인스턴스에 접근 가능=> 싱글턴.

  • 구현은 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class FileSystem
{
public:
  static FileSystem& instance()
  {
    // Lazy initialize.
    if (instance_ == NULL) instance_ = new FileSystem();
    return *instance_;
  }

private:
  FileSystem() {}

  static FileSystem* instance_;
};
  • instance_ 정적 멤버 변수는 클래스 인스턴스를 저장한다.
  • 생성자가 private이므로 오직 내부에서만 생성가능하다.
  • instance()는 필요할 때까지 인스턴스 초기화를 지연한다.

  • 요즘 구현은 이렇다.
1
2
3
4
5
6
7
8
9
10
11
12
class FileSystem
{
public:
  static FileSystem& instance()
  {
    static FileSystem *instance = new FileSystem();
    return *instance;
  }

private:
  FileSystem() {}
};
  • C++11에서는 정적 지역 변수 초기화 코드가 멀티스레드 환경에서 딱 한 번 실행되어야한다.
    • 즉, 위와 같이 구현하면 초기화는 스레드 안전하다.

Why We Use It

장점

  • 한 번도 사용하지 않으면 인스턴스를 생성하지 않는다.
    • CPU와 메모리 사용량 감소.
  • 런타임에 초기화
    • 싱글턴의 대안 중 하나인 정적 멤버 변수
    • 정적 멤버 변수는 자동 초기화 문제가 있다.
      • 즉, 프로그램 실행 후 알 수 있는 정보를 활용할 수 없음.
      • 정적 변수 초기화 순서 또한 컴파일러에서 보장하지 않는다.(순환 의존만 없다면, 싱글턴의 게으른 초기화는 이를 해결)

        마이어스 싱글턴으로 자동 초기화 걱정없이 정적변수로 싱글턴을 만들 수 있다.

  • 싱글턴을 상속할 수 있다.
    • 단위 테스트용 모의 객체(Mock object)를 만들 때 유용하다.
    • 추상 인터페이스 => 플랫폼별 클래스

Why We Regret Using It

  • 길게 보면 비용을 지불하게된다.
  • 단점은 다음과 같다.

It’s a global variable

  • 전역 변수와 정적 변수를 많이 사용하게 되면, 게임이 점차 커지고 복잡해짐에 따라 설계와 유지보수가 병목이 된다.
  • 하드웨어 한계보다 생산성한계 때문에 출시가 늦어질 가능성이 더 높아졌다.

전역 변수의 단점

  • 코드를 이해하기 어렵게한다.
    • 전역변수를 건드리지 않으면, 함수코드와 매개변수만 보면된다.

      컴퓨터 과학에서는 전역 상태에 접근하거나 수정하지 않는 함수를 ‘pure’ 함수라고 한다. 순수함수는 이해하기 쉽고 컴파일러가 쉽게 최적화할 수 있다.=> 순수 함수형 언어: 하스켈

  • 전역 변수는 커플링을 조장한다.
    • 인스턴스에 대한 접근을 통제함으로써 커플링을 통제할 수 있다.
  • 전역 변수는 멀티스레딩 같은 동시성 프로그래밍에 알맞지 않다.

    • 교착상태, 경쟁 상태 등 스레드 동기화 버그가 생기기 쉽다.
  • 싱글턴 == 클래스로 캡슐화된 전역 상태

It solves two problems even when you just have one

  • 싱글턴은 두 문제를 해결한다.

    • 보통 ‘전역 접근’이 싱글턴 패턴을 선택하는 이유이다.
  • ex)로깅 클래스를 생각해보자

    • 모든 함수에 Log 클래스 인스턴스를 추가하는 것은 번잡하고 읽기 어렵다.
    • 싱글턴을 사용하면 되지만, 하나만 만들 수 있다는 제한이 생긴다.
    • 로그 파일을 나누어서 저장하는게 찾기 쉽다.
    • 여러 파일에 나누면, 분야별로 로거를 만들어야한다.
    • Log 가 싱글턴이면 이럴 수 없다.
    • Log::instance().write("Some event.");
    • 이제 Log 클래스를 사용하는 모든 코드를 손봐야한다.

Lazy initialization takes control away from you

  • 제어 불가능한 2가지 이유

    1. 게임에서 게으른 초기화는 좋지 않다.
    • 게임 도중에 화면 프레임이 떨어질 수 있다.
    1. 메모리 단편화(fragmentation)를 막기 위해 힙에 메모리를 할당하는 방식을 세밀하게 제어하는게 보통이다.
    • 적절한 초기화 시점을 찾아야한다.
  • 대신 아래와 같이 구현하면 해결할 수 있다.
1
2
3
4
5
6
7
8
9
10
class FileSystem
{
public:
  static FileSystem& instance() { return instance_; }

private:
  FileSystem() {}

  static FileSystem instance_;
};
  • 대신 그냥 전역 변수보다 나은점을 몇개 포기해야한다.

    • 정적 인스턴스를 사용하면 다형성을 사용할 수 없다.
    • 클래스는 정적 객체 초기화 시점에서 생성된다.
    • 인스턴스가 필요없어도 메모리를 해제할 숭 없다.
  • 이는 정적 클래스와 같다.
  • 차라리 정적 함수를 사용하는것이 낫다.
    • 더 분명하게 정적 메모리에 접근한다는것을 보여줌
    • Foo::bar()

What We Can Do Instead

See if you need the class at all

  • 클래스가 꼭 필요한가?
  • 싱글턴은 보통 객체 관리용으로 존재한다.(manager, system, engine)
    • 서툴게 만든 싱글턴은 다른 클래스에 기능을 더해주는 ‘helper’인 경우가 많음.
    • 객체 스스로를 챙기게 하는 게 바로 OOP
    • 관리자 클래스를 없애고, 합침.

To limit a class to a single instance

  • 전역접근: 구조가 취약하짐
    • 특정 코드에서만 접근하게 or private 멤버 변수
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class FileSystem
{
public:
  FileSystem()
  {
    assert(!instantiated_);
    instantiated_ = true;
  }

  ~FileSystem() { instantiated_ = false; }

private:
  static bool instantiated_;
};

bool FileSystem::instantiated_ = false;
  • 이 클래스는 어디서나 인스턴스를 생성할 수 있다.
  • 하지만, 인스턴스가 둘 이상 되는 순간 단언문에 걸린다.
  • 이는 하나의 인스턴스를 보장한다.
  • 런타임에 인스턴스 개수를 확인한다는게 단점.

    단언문: 코드에 제약

To provide convenient access to an instance

  • 쉬운접근성: 원치 않는 곳에서도 쉽게 접근할 수 있다는 비용

  • 변수는 최대한 적은 범위로 노출하는게 좋다.

    • 기억해야할 코드양이 줄어든다.

Pass it in

  • 객체를 넘겨주는것이 가장 쉬우면서 최선인 경우

    “dependency injection” 이라고 부름, 필요로하는 의존 객체를 전역에서 찾는 대신 매개변수로 받아 사용하는 방식(의존성 주입)

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

    • 렌더링하려면 렌더링 상태를 담고 있는 그래픽 디바이스 대표 객체에 접근할 수 있어야 한다.
    • 이럴 때, 일반적으로 모든 렌더링 함수에서 context 같은 이름의 매개변수를 받음.
  • 하지만 로그같은 경우, 핵심이 아니기 때문에 인수에 추가하기 어색함.

    로그 == 횡단 관심사(cross-cutting concern), 이를 깔끔하게 다루는 것은 구조를 잡을 때 계속 고민해야 할 문제, 정적 타입 언어에서 특히 그럼, 관점지향 프로그래밍의 등장 이유

Get it from the base class

  • 많은 게임에서 클래스를 대부분 한 단계만 상속할 정도로 상속 구조를 얕고 넓게 가져간다.
    • ‘leaf’에 해당하는 하위클래스가 대부분
    • 많은 클래스에서 같은 객체, 즉 GameObject 상위 클래스의 객체에 접근가능하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class GameObject
{
protected:
  Log& getLog() { return log_; }

private:
  static Log& log_;
};

class Enemy : public GameObject
{
  void doSomething()
  {
    getLog().write("I can log!");
  }
};
  • 위처럼 구현하면, 로그 객체에 접근할 수 있다.
  • protected 매서드를 활용해 구현하는 방식은 하위 클래스 샌드박스 패턴에서 확인할 수 있다.

GameObject가 생성해, 정적 인스턴스로 들고 있는것이 가장 쉽다. 아니면 Log 객체를 얻을 수 있는 초기화 함수 제공, 서비스 중개자 패턴을 사용해도 좋다.

Get it from something already global.

  • 전역 상태를 모두 제거하는 것은 어렵다.
  • 결국에는 Game이나 World같이 전체 게임 상태를 관리하는 전역 객체와 커플링되어 있다.

  • 기존 전역 객체에 빌붙는 방법
    • Log, FileSystem, Audio, Player를 각각 싱글턴으로 만드는 대신 다음과 같은 코드를 작성할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Game
{
public:
  static Game& instance() { return instance_; }

  // Functions to set log_, et. al. ...

  Log&         getLog()         { return *log_; }
  FileSystem&  getFileSystem()  { return *fileSystem_; }
  AudioPlayer& getAudioPlayer() { return *audioPlayer_; }

private:
  static Game instance_;

  Log         *log_;
  FileSystem  *fileSystem_;
  AudioPlayer *audioPlayer_;
};

순수주의자들에 의하면 디미터의 법칙에 위배된다고 한다.

디미터의 법칙: 다른 객체가 어떠한 자료를 갖고 있는지 속사정을 몰라야한다는 것을 의미.

  • 이제 Game 클래스 하나만 전역에서 접근할 수 있다.
    • 다른 시스템에 접근하려면 다음 함수를 호출하면 된다.
    • Game::instance().getAudioPlayer().play(VERY_LOUD_BANG);
  • 나중에 Game 인스턴스를 여러 개 지원하도록 구조를 바꿔도, Log 등은 영향받지 않는다.
    • 더 많은 코드가 Game과 커플링된다는 단점이 생김.
    • 이 문제는 여러 방법을 조합해 해결할 수 있다.
      • Game클래스를 모르는 코드는 넘겨주거나, 상위 클래스로부터 얻기 등을 통해야한다.

Get it from a Service Locator

  • 여러 객체에 대한 전역 접근을 제공하는 용도로만 사용하는 클래스를 따로 정의하는 방법도 있다.
  • 이는 서비스 중개자 패턴에서 다룬다.

What’s Left for Singleton

  • 진짜 싱글턴 패턴이 필요한 때

    • 게임에 그대로 적용하기에는 어렵다.
    • 인스턴스 하나 제한 => 정적 클래스
    • 클래스 생성자에 정적 플래그 => 런타임에 인스턴스 개수 검사
  • 하위 클래스 샌드박스 패턴: 클래스가 같은 인스턴스들이 공용 상태를 전역으로 만들지 않고도 접근할 수 있는 방법을 제공

  • 서비스 중개자 패턴: 객체를 전역으로 접근할 수 있게 하되, 객체를 훨씬 유연하게 설정할 수 있는 방법을 제공

출처

singleton

디미터의 법칙

This post is licensed under CC BY 4.0 by the author.

[게임 프로그래밍 패턴] Design Patterns Revisited: Prototype

[백준][C++] 11000: 강의실 배정(greedy)