[Unity] 유니티 싱글턴 패턴 사용 ( Singleton Pattern )

TNT·2024년 1월 11일
1

유니티 디자인패턴

목록 보기
13/14
post-thumbnail

싱글턴 패턴

항상 코드 디자인 패턴 사용하는 글 이나 팁 같은 게시글 보면
나오는 말중에 싱글턴 패턴을 남발하지마라. 만들지 마라.
등등 이런 말이 자주 나온다.

하지만 쓰기 너무 좋고 편하다. 프로토 타입이나 혼자 게임을 만들고 있으면
구조를 고민 하는데 그냥 싱글턴으로 매니저 두고 할까 하는 생각을 많이 하는거 보면 남발 하고있긴 한거같다.
그러면 이번에 소개 할껀 싱글턴 패턴을 설명 하기도 하지만 피해야할경우도 설명할 예정이다.
자신이 코딩 하고있는데 무언가 코드가 막혀서 이걸 보고 해당하는 구조를 가지고 있다면 수정해보면 좋을꺼같다.
그러면 먼저 싱글턴 패턴을 보자

1. 한개의 클래스 인스턴스만 갖도록 보장

인스턴스가 여러개면 제대로 동작하지 않는 상황이 있다.
싱글턴 패턴 공부 하다 보면 항상 먼저 보게 되는 말이다, 유일성 보장을 해야한다고 한다.
특히 외부 시스템이랑 상호 작용하는경우 그렇다. 파일 시스템 API를 래핑하는 클래스가 있다고 해보면 작업하는데 시간이 걸리기때문에 비동기로 작업한다고 가정하자.
비동기 이기 때문에 여러 작업이 동시에 진행할수도있고 삭제와 생성이 충돌 나면 안되서 서로 작업 중일때 못들어오게 막아야한다.
이걸 보면 lock 개념이 생각난다.

1-1 전역 접근점을 제공

로딩, 게임 저장 등등 내부 시스템에서 파일 시스템 래퍼 클래스를 사용한다.
그러면 이런 파일 시스템 클래스 인스턴스를 생성 따로 못하면 접근을 어떻게할까?

여기서 싱글턴 패턴은 해결책을 제공한다.
인스턴스 + 전역으로 접근할수있는 메서드를 제공해준다.

FileSystem.cs

public class FileSystem : MonoBehaviour
{
    static FileSystem _instance;

    public FileSystem Instance()
    {
        if(_instance == null)
        {
            _instance = new FileSystem();
        }
        return _instance;
    }
}

가장 자주 보는 형태의 방식이다.
_instance 정적 멤버 변수에 인스턴스를 저장하고 public으로 선언한
Instance() 제외하고 코드를 밖에서 접근이 불가능하다.

정적 메서드는 코드 어디에서나 싱글턴 인스턴스에 접근할수있게 했고
싱글턴에 필요한 인스턴스 초기화를 미루는 역활도 한다.

이정도로면 사용 안할 이유가 없다.
파일시스템 래퍼를 번거롭게 주고받고 안해도 좋고 어디에서나 받을수도있다.
인스턴스를 여러 개 만들수도 없어서 상태도 이상하게 꼬일 일도 없다.

한번도 사용하지 않는다면 아예 인스턴스를 생성하지 않는다.
싱글턴은 처음 사용이 되서 요청이 들어와야 초기화 한다.
만들어두고 게임 내에서 전혀 사용하지 않는다면 초기화 될일이 없다.

1-2 런타임에 초기화 된다.

보통 싱글턴을 안쓰려고 대안으로 두는게 정적 멤버 변수를 많이 사용한다.
정적 멤버 변수는 자동으로 초기화 되는 문제가 있긴하다.
컴파일러는 main함수를 호출하기전에 정적 변수를 초기화 하기 때문에 정보를 미리 저장하는
값으로 활용 하긴 어렵다. 또한 초기화 순서도 보장을 안해주기때문에 다른 변수에 의존할수도 없다.

위에있는 게으른 초기화는 이걸 해결해준다. 싱글턴이 최대한 늦게 초기화 하게 해서
싱글턴이 사용될때쯤이면 이미 클래스에 필요한 정보들이 세팅된 상태 일것이다.
순환 의존만 없다고 하면 초기화할때 다른 싱글턴을 참조 해도 괜찮다.

1-3 싱글턴을 상속할수있다.

파일 시스템 래퍼가 크로스 플랫폼이나 이런곳에 지원 해야한다고 하면
추상 인터페이스를 만든뒤 플랫폼 별로 클래스 만들면 된다.

FileSystem.cs

public class FileSystem : MonoBehaviour
{
    static FileSystem _instance;

    public FileSystem Instance()
    {
        if (_instance == null)
        {
            _instance = new FileSystem();
        }
        return _instance;
    }

    public virtual string ReadFile(char path) { return ""; }

    public virtual void WriteFile(char path, char contents) { }
}

부모 인터페이스를 만들고 각각 사용될 클래스를 만들어주자.

PS3FileSystem.cs

class PS3FileSystem : FileSystem
{
    public override string ReadFile(char path)
    {
        // 플스 저장소
        return "";
    }
    public override void WriteFile(char path, char contents)
    {
        //플스 저장소
    }
}

WillFileSystem.cs

class WillFileSystem : FileSystem
{
    public override string ReadFile(char path)
    {
        // 닌텐도 저장소
        return "";
    }
    public override void WriteFile(char path, char contents)
    {
        //닌텐도 저장소
    }
}

그다음 다시 위로가서 파일 Instance 하는 부분에 수정해주자

 public FileSystem Instance()
    {
        if (_instance == null)
        {
# if PLATFORM == PLAYSTATION3
            _instance = new PS3FileSystem();
#elif PLATFORM == WII
            _instance = new WillFileSystem();
#endif
        }
        return _instance;
    }

파일 전처리기 지시문 이용해서 컴파일러가 시스템에 맞는 객체를 만들게 해줄수도있다.
그러면 왜 싱글턴이 문제라고 하는걸까?
장점들과 사용처를 알았으면 반대경우도 살펴 봐야한다.

짧게 보면 싱글턴 패턴에 큰 문제가 없다.
하지만 꼭 필요하지않고 개발이 가능한곳에서도 싱글턴을 적용하면 문제가 생긴다.

2. 전역 변수

예전에는 소프트웨어 엔지니어링 이론보다 하드웨어 성능을 얼마나 쓰냐가 중요했다.
C와 어셈블리어로 전역 변수와 정적 변수 마구마구 써도 문제 없이 멋진 게임을 낼수있엇다.
하지면 점점 난이도 가 높아지고 게임이 커지고 복잡해지고 그래픽이 좋아지닌까
설계와 유지보수성이 병목이 되기 시작했다.
예전 말에 간단한 프로그램일수록 버그가 없다. 이런 말을 들었던거같다.

개발자들이 c++로 오면서 전역변수가 나쁘다 이걸 말하는건데 이유는
전역 변수는 코드를 이해하기 어렵게 한다.
남이 만든 함수에서 버그를 찾아야하는 업무를 받게 된다면 함수가 전역 상태를 건드리지 않는다면
함수 코드와 매개변수만 확인하면 된다.

예시로 함수에 SomeClass.GetSomeGlobalData()에서 에러가 있다면 전체 코드에서
GetSomeGlobalData()를 호출 하는곳을 다 돌면서 변수값과 동작을 확인해야한다.
정적변수를 값을 이상하게 바꾸는 곳을 찾기위해서 말이다. 10개 이하도 힘든데 호출하는곳이 100곳넘어가면
너무 힘들다.

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

유지보수 하기 좋게 코드를 짜둬서 느슨하게 결합해놓은 아키텍처가 있다고 가정해보자
신입이 와서 돌맹이가 땅에 떨어지때 소리가 나게 하는 작업을 줬다고 해보자.
기존 구조를 알고있는 작업자들은 물리코드와 사운드 코드 사이에 커플링이 생기는걸 피하려고 하겠지만
신입에게는 주어진 작업을 끝내고 싶은 마음 뿐이라서 AudioPlayer 인스턴스에 전역으로
접근할수있어서 변경할수도있다.

AudioPlayer 전역 인스턴스를 만들지 않았더라면 막을수있다.
인스턴스에 대한 접근을 통제해서 커플링을 통제 할수있다.

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

싱글코어로 게임을 실행하던 게임은 오래전이다 요즘은 멀티스레딩을 활용 못하더라도 비슷하게 라도
맞게 코드를 짠다.
일단 전역으로 만들면 다른 모든 객체에서 보고 수정할수있는 영역이 생긴다.
다른 스레드가 전역데이터에 무슨 작업을 할지 모르기 때문에 이때 잘못하면 교착상태, 경쟁상태 서버프로그래밍 하면서 많이보던 문제를 보게된다.
이러면 찾기도 힘든 스레드 동기화 버그가 생기기 쉽다.

전역변수라고 말한거 전부 싱글턴으로 넣고 다시 보자.
즉 싱글턴에서 생길수있는 문제가 얼마나 많은지 확인 가능하다.
그러면 이런 전역 상태 없이 게임을 만들수 있나?

방법이 있긴하지만 쉽지 않고 싱글턴 자체가 또 사용이 좋기 때문에 많이 쓴다.
지금까지 사용했던 방식으로 또 설계할수있어서 익숙 하기도 하다.

하지만 전역 상태때문에 생길수있는 문제를 쭉 보면 싱글턴으로 해결 할수없다.
싱글턴 패턴 자체가 클래스로 캡슐화된 전역 상태이기 때문이다.

싱글턴은 문제가 하나뿐일때도 두가지 문제를 풀려 든다.
보통 문제를 해결하려고하면 인스턴스를 한개로 강제하고 싶을뿐이고 전역접근은 원치않는경우
아니면 전역 접근은 허용하지만 인스턴스는 여러개 일수도 있다면?

보통 싱글턴 쓰는이유가 전역 접근을 위해서 쓰는경우가 많을것이다.
로그 찍는 클래스가있다고 한다면 모든 진단 정보를 로그로 남기면 편하다
하지만 모든함수안에 인수에 로그 클래스 인스턴스를 추가하면 메서드가 번잡해지고 알기힘들다.
가잔 편한건 로그 클래스를 싱글턴으로 만들어서 인스턴스를 얻으면 된다.
하지만 로그 객체가 하나만 만들수있다.
처음에는 문제 없이 잘 쓴다 테스트도 하고
하지만 파일 하나에 다쓴다면 인스턴스도 하나면 충분하다. 개발팀 모드가 로그를 남겨버려서
로그파일이 알기 힘들어진다. 로그도 파일 여러개로 나누면 좋다.
네트워크, ui, 오디오, 플레이어 등등 분야별로 나눠야한다.
하지만 싱글턴 인스턴스 하나를 보장하기 때문에 로그를 나눌순 없다.
이런 설계는 로그 클래스를사용하는 모든코드에 영향을 준다.
클래스와 클래스를 사용하는 코드를 전부 수정해야한다. 여러곳에서 접근이 가능하는 장점이 단점이 되어버렸다.

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

가상 메모리도 사용할수 있고 성능이 좋은 pc 에서는 게으른 초기화가 괜찮다.
하지만 게임에서는 다른점이 시스템을 초기화 할때 메모리 할당, 리소스 로딩 딩 할일이 많아서
시간이 소오 될수있고 오디오 같은 작업이 걸리는 경우 초기화 시점을 제어해야한다.
처음 소리 재생 할때 게으른 초기화를 한다면 전투도중에 초기화가 시작 되는 바람에
화면 프레임 떨어짐 이나 버벅임이 있을수있다.

게임에서는 메모리 단편화를 막기 위해서 메모리를 할당하는 방식을 세밀하게 제어하는 게 보통이다.
오디오 시스템이 초기화될 때 힙영역에 할당한다면 어디에 할당할지를 제어할수있도록 초기화 시점을 찾아야한다.

이러한 두 가지 문제 때문에 게으른 초기화를 사용하지 않고 대신 싱글턴 패턴을 고칠수 있다.

FileSystem.cs

public class FileSystem : MonoBehaviour
{
    private static FileSystem _instance;

    public static FileSystem Instance()
    {
        return _instance;
    }
}

이렇게 된다면 게으른 초기화 문제는 해결할 수 있지만, 싱글턴이 전역 변수 보다 나은점은
몇개 포기해야한다. 정적 인스턴스를 사용하면 다형성을 사용할수 없고,
클래스는 정적 객체 초기화 시점에 생성 된다. 인스턴스가 필요없어져도 메모리를 헤제할 수 없다.

싱글턴 대신 정적 클래스를 만들었다고 봐도 될듯하다.
그러면 그냥 정적 함수로 사용하면 된다.
Foo.bar() | Foo.Instance.bar() 중에 왼쪽이 좀더 깔끔하다. 정적 메모리에 접근하는걸 분명하게 보여준다.

그러면 이제 싱글턴을 쓰기전에 생각을 한번더 할수있다.
싱글턴이 진짜 필요할까??
아니다라고 생각이 들면 그러면 이거 말고 뭘써야하지? 라는 문제에 놓이는데

3. 대안

3-1 클래스가 꼭 필요한가?

싱글턴 패턴을 이용해서 본 클래스중에 에매하기 다른 객체 관리용으로만 존재하는 클래스도 있다.

Monster > MonsterManager
Particle > ParticleManager
Sound > SoundManager
Manager > ManagerManager

등등 모든 클래스에 관리자를 넣어버리면서 하는 경우이다.
물론 관리가 필요한 클래스도 있지만
이걸보자.

Bullet.cs

public class Bullet : MonoBehaviour
{

    int _x;
    int _y;

    public int GetX() { return _x; }
    public int GetY() { return _y; }
    public void SetX(int x) { _x = x; }
    public void SetY(int y) { _y = y; }
}

BulletManager.cs

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

여기서 카메라에 보이는 Bullet들을 관리 하려고 생각해서 보통 매니저를 하나 둔다.
그리고 보다보면 bullet은 여러개잖아? 하고 그러면 매니저를 싱글턴으로 만들어야겠다 생각한다.
하지만 이렇게 하면 인스턴스 없이 충분히 관리할수있다.

Bullet.cs

public class Bullet : MonoBehaviour
{

    int _x;
    int _y;
    public Bullet(int x, int y)
    {
        x = _x;
        y = _y;
    }

    bool IsOnScreen()
    {
        return _x >= 0 &&
               _y >= 0 &&
               _x < Screen.width &&
               _y < Screen.height;
    }

    void Move()
    {
        _x += 5;
    }
}

매니저를 없애고 나닌까 싱글턴으로 매니저를 둬서 관리해야하는 문제도 없어진다.
엉성하게 만든 싱글턴은 다른 클래스에 기능을 더해주는경우 수준이 많다.
그러면 없애도 괜찮은 매니저 클래스 들이 눈에 들어올꺼다.
객체가 기능을 각자 챙기는게 OOP다.

3-2 한 개의 클래스 인스턴스만 갖도록 보장하기

싱글턴 패턴이 해결하려는 첫 번째 문제다.
위에서 본 파일 시스템 예제에서 클래스 인스턴스를 하나만 있도록 보장하는건 중요하다.
하지만 어디에서나 접근할수있게 하는것도 있지만 특정 코드에서만 또는 클래스의 Prevate 멤버 변수를 만들고 싶을 수도 있다.
이럴때 전역으로 접근하면 구조가 취약해진다. 전역 접근 없이 클래스 인스턴스를 한 개를 보장하는 방법이 몇가지 있다.

인스턴스 만들때 bool 변수 하나 둬서 ture 해주고 삭제할때 false로 변경해주면
이것도 가능하긴하다.
다만 싱글턴은 클래스 문법을 활용해서 컴파일 시간에 단일 인스턴스를 보장하는데
이런 방삭은 런타임에 인스턴스 개수를 확인한다는 단점이 있다.

3-3 인스턴스에 쉽게 접근하기

쉬운 접근성은 싱글턴을 선택하는 가장 큰이유다.
하지만 원치 않는 곳에서도 접근이 가능하다.

변수는 작업 가능한 선에서 최대한 적은 범위로 노출하는게 일반적으로 좋다.
변수가 보이는게 적을수록 코드를 볼때 보기 편해진다.
그러면 객체에 접근할수있는 다른 방법을 고민해보자.

3-4 넘겨주기

객체를 필요로 하는 함수 인수에 넘겨주는게 가장 쉽고 최선인 경우가 많다.

3-5 상위 클래스로부터 얻기

많은 게임에서는 클래스를 대부분 한단계정도면 상속하는경우가 많다. 몬스터 > 슬라임 등등
객체가 상속 받는 Monsterobject 라는 상위 클래스가 있다고 해보자
이런 구조에서는 게임 코드의 많은 부분이 하위 클래스에 있다.
많은 클래스에서 같은 객체을 접근할수있다.
이부분을 이용한다면

Monsterobject.cs

public class Monsterobject : MonoBehaviour
{
    protected Log GetLog() { return log_; }

    private Log log_;
}

class Enemy : Monsterobject
{
    void doSomething()
    {
        GetLog().write("log");
    }
}

이런 방향으로 작성도 가능하다.

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

전역 상태를 모두 제거하는건 너무 어렵다.
결국 게임이랑서 게임을 관리하는것과 월드같이 전체 게임을 상태를 관리하는 전역 객체와 커플링되어 있기 때문이다.
기존 전역 객체에 빌붙으면 전역 클래스 개수를 줄일수 있긴하다.

Log, FileSystem, Audio, Player를 각각 식들턴으로 만드는 대신 이렇게 해보자.

GameMamager_.cs

public class GameMamager_ : MonoBehaviour
{
    public static GameMamager_ instance() { return instance_; }
    public Log getLog() { return log_; }
    public FileSystem GetFileSystem() { return fileSystem_; }
    public AudioPlayer GetAudioPlayer() { return audioPlayer_; }


    private static GameMamager_ instance_;
    private Log log_;
    private FileSystem fileSystem_;
    private AudioPlayer audioPlayer_;

}

이렇게 구성한다면 게임 메니저 클래스 하나만 전역에서 접근해서 사용가능하다.
GameManager.instance().getAudioPlayer().play("audio01");
이런 느낌으로 될것이다. 나중에 Game 인스턴스를 여러 개로 늘려도
Log, FileSystem, Audio는 영향 받지 않는다.
대신 더많은 코드가 GameMamager 클래스에 커플링 된다는 단점이있다.
사운드 하나만 출력하고 싶어도 GameMamager 클래스를 알아야한다.
이런 문제는 여러 방법을 조합해서 해결 할수있다.

이미 GameMamager 클래스를 알고있는 코드에서는 AudioPlayer를 받아서 사용하면 된다.
하지만 모르는곳에서는 넘겨주기로 객체를 넘겨주거나 상위클래스가 알면 얻기를 통해서 접근하면 된다.

3-7 서비스 중개자로부터 얻기

지금까지는 전역 클래스가 Game 같은 일반적이 구체 클래스라고 가정했다.
하지만 여러 객체에 대한 전역 접근을 제공하는 용도로만 사용하는 클래스를 따로 정의하는 방법도 있다.
이 패턴은 나중에 따로 게시글 올리겠다.

4. 개인적인 싱글턴에 남은 것

그렇다면, 진짜로 싱글턴 패턴이 필요 한때는 언제일까?
자신이 컨트롤 가능한 수준에서 사용하면 좋을꺼같다.
대체 할수있는 패턴인 샌트박스 패턴, 서비스 중개자 패턴도 있긴하닌까 나중에 게시글 올릴수있으면 올리겠다.

profile
개발

0개의 댓글