본문 바로가기

일반

Singleton 패턴을 써야 할까?

들어가며..

싱글턴 패턴은 항상 많은 의견이 나오는 주제이다. 디자인 패턴 중 가장 쉬운 패턴인 것도 있고 가장 사용빈도가 높은 패턴 중 하나이기도 하다. 또한 안티패턴이다 vs 하나의 패턴일 뿐이다 로 의견이 많이 갈리는 주제이기도 하다.

 

이 글에서는 약간 다른 방향으로 접근해보려 한다.

 


전역변수처럼 쓰이는 싱글턴

싱글턴을 간단하게 정의하면 인스턴스가 한개만 존재해야 하는 상황을 모델링한 것이다.

이걸 C#에서는 생성자를 private으로 숨겨서 직접 생성할 수 없게 하고 대신 정적 인스턴스 프로퍼티를 만들어서 접근하는 식으로 구현하곤 한다.

// 흔히 사용하는 제네릭 부모 클래스 형태의 싱글턴
public class Singleton<T> where T : class
{
    private static Singleton _instance;
    public static Singleton Instance
    {
        get
        {
            if (_instance is null)
            {
                _instance = new T();
            }
            return _instance;
        }
    }
}

 

이렇게 싱글턴을 구현하면 자연스럽게 전역에서 접근할 수 밖에 없게 된다. 인스턴스가 없으니까 전역 접근을 할 수밖에 없기 때문이다. 그런데 게임개발을 하면서 싱글턴을 사용하다 보면 인스턴스가 한개만 존재해야 한다는 점 보다는 단순히 이러한 전역 접근이 편리해서 전역 변수를 쓰듯이 쓰는 경우가 많이 있다.

 

왜냐하면 게임의 특성상 만들어야 하는 구조 자체가 깔끔하게 기능별로 나뉘어진다는 보장이 없기 때문이다. 예를 들면 메인 화면에서 3단계쯤 들어간 어느 화면에서 갑자기 메인화면 인스턴스를 참조해야 하는 경우 등이 있다. 

 

UI 흐름의 에시

 

 

A,B,C,D가 각 UI의 한 화면이라고 생각해보자. B는 A에 있는 정보로 구성할 수 있다. C는 B에 있는 정보로 구성할 수 있다. 그런데 D에서 A의 인스턴스가 필요해진다. 이럴 때 어떻게 할까? 1) A를 전역에서 접근할 수 있다. 2) B,C가 자기는 사용하지도 않을 A의 인스턴스를 보관했다가 넘겨준다. 이 두가지 옵션 중 하나를 골라야 할 수 밖에 없다.

 

이런 상황이 한두군데라면 모르겠으나 여러군데에서 발생하면 둘 중 1번이 더 좋을 수 밖에 없다.

결국 이렇게 하나둘씩 싱글턴이 늘어나게 된다.실제로 게임 코딩을 해 본 사람들 중 xxxManager라는 싱글턴 클래스를 만들어 보지 않은 사람은 없을 것이다.


그 목적으로 싱글턴이 꼭 필요한가

그런데 게임을 만들면서 접하는 거의 모든 시스템은, 인스턴스가 딱 한개만 있지 않으면 문제가 되는 일은 거의 없다. 오히려 인스턴스가 한개로 제한되어서 발생하는 문제점이 훨씬 많을 것이다. 예를 들어 메인화면은 단 하나뿐이니까 싱글턴으로 만들었다고 가정하자. 그런데 개발 중 기획 추가로 "친구 방문" 기능을 구현하게 되었다. 그런데 "친구 방문"을 하게 되면 친구의 메인화면을 봐야 한다고 가정하자. 그러면 더 이상 싱글턴으로 구현할 수 없게 될 것이다.

 

이처럼 단순히 인게임 컨텐츠 등을 싱글턴으로 만드는 경우엔 얼마든지 여러개의 인스턴스를 만들어야 하는 상황에 빠질 수 있다 이런 경우를 대비해서 상속받아 쓰는 제네릭 싱글턴보다는 평범한 클래스에 전역 접근할 수 있는 정적 프로퍼티를 하나 달아놓는 형태가 더 바람직하다. 이런 형태는 C# 기본 라이브러리에서도 쉽게 찾아볼 수 있는데, System.Buffers.ArrayPool<T>.Shared 등을 예로 들 수 있다.

 

 

 

ArrayPool<T> Class (System.Buffers)

Provides a resource pool that enables reusing instances of type T[].

learn.microsoft.com

 

ArrayPool<T>는 직접 생성해서 인스턴스를 관리할 수도 있고, Shared 등으로 간편하게 접근해서 사용할 수도 있다. 이런 형태가 일반적으로 더 바람직하다고 생각한다.

// 인스턴스를 생성할 수도, 전역 접근할 수도 있는 형태
public class Something
{
    public static Something Shared { get; }
    
    static Something()
    {
    	Shared = new Something();
    }    
}

 


정말로 절대로 둘 이상 존재하지 않는 클래스라면?

그냥 static class를 사용하면 되지 않을까?

 


수많은 전역 변수들 사이에서 길 찾기

그러나 여전히 전역 변수는 최대한 지양하는게 좋다. 전역 변수 자체가 나쁘다기보단, 코드 관리의 측면에서 상태의 수는 최대한 적은게 좋기 때문이다. 그리고 전역 변수는 모든 범위에서 접근할 수 있는 상태이다.

 

1. 그래도 최대한 줄이자

원하는 기능에 접근하는 방법이 마땅치 않아서 일단 필요한 인스턴스를 전역으로 뺀 다음에 접근하는 코드를 만든 경험은 누구에게나 있을 것이다. 특히나 C#에서는 강력한 리플렉션 기능을 통해 웬만한 작업은 모두 런타임에서도 어떻게든 가능하기 때문에, 더욱 전역 변수을 쓰고 싶은 마음이 많이 든다.

그래도 간단하게 인스턴스를 전달할 수 있는 방법이 있을지 최대한 찾아봐야 한다. 구조 변경을 고민하는 데는 많아봐야 몇십분 걸리지만, 나중에 코드를 읽을 때는 몇 시간이 걸려서야 문제를 발견할수도 있다. 정말 긴급하게 구현해서 넘겨야 한다던가 하는 상황이면 어쩔 수 없지만, 비교적 간단하게 의존성을 주입할 수 있는 상황에서도 전역 변수를 쓴다는건 프로그래머의 게으름을 보여주는 것이다.

 

 

2. 상태 대신 서브시스템 개념으로

흔히 게임에서 전역으로 접근할만한 대상은 UI시스템, 카메라, 타이밍 등이 있다. 이 때 이것들을 어떤 전역 변수로 간주하기보단 시스템으로 생각하면 정리에 큰 도움이 된다. 유니티의 Time이 좋은 예라고 할 수 있는데, Time.time이나 Time.deltaTime등으로 앱에서 전역으로 접근할 수 있는 공통 시간값을 가지고는 있지만, Time클래스 자체가 어떤 상태로서 기능하지는 않는다. 단순히 현재 사용할 수 있는 여러 종류의 시간값에 접근할 수 있을 뿐이다.

다른 예로는 System.IO.File 이 있다. 이 클래스는 파일시스템에 대한 접근을 할 수 있게 해 주는데, File.WriteAllText 등으로 파일시스템에 어떤 명령을 내릴 수 있을 뿐 상태로서 기능하지는 않는다. 마찬가지로, 예를 들어 UI시스템에 대한 정적 인스턴스가 있다면 UI.CreateView("Something") 같은 식으로, 그 자체가 상태로서 기능하기보단 UI 인스턴스를 가져오거나 파괴할 수 있는 시스템으로서 접근하면 전역 변수가 가져오는 이득을 누리면서 폐해는 최소화 할 수 있다.

 

 

3. 정적 클래스도 클래스다.

정적 클래스 혹은 싱글턴 클래스를 만들 경우 그 자체가 OOP에 반하는 것이라고 생각해서인지 설계를 대충 하는 경우가 많다. 그러나 정적 클래스 등도 인스턴스가 한개일 뿐 엄연히 클래스이다. 단일 책임의 원칙이나 개방-폐쇄의 원칙 등 일반적인 클래스 작성에서 흔히 사용하는 원칙을 잘 적용하면 전역 접근을 가능한 클래스를 사용하면서도 악영향을 최소화 할 수 있다.


결론

싱글턴 (혹은 정적 클래스/인스턴스)를 잘 사용하면 복잡한 의존성 주입 과정을 최소화하고 개발과 유지보수가 편한 코드를 만들 수 있다. 일단 최대한 지양하되 필요한 경우엔 조심스럽게 사용하는 것이 가장 좋다고 볼 수 있다.

특히 게임프로그래밍의 경우 요구사항 자체가 모든 곳에서 모든 곳을 참조해야 하는 경우가 많고, 일반적으로 모듈별 재사용이나 단위 테스트 등을 잘 안하는 경우가 많다는 점에서 DI나 Service Locator 등을 도입했을 때 얻는 이득이 생각보다 크지 않을 수 있다. 유니티에는 Zenject같은 훌륭한 DI프레임워크도 있지만, 일단 단순한 정적 인스턴스를 먼저 고려하는 편이 더 좋은 경우가 많다.