본문 바로가기

💻 내 소개 안녕하세요 엄청짱 프로그래머 손다빈 입니다.
  • 나이 : 96년생
  • 특이사항 : MZ세대, INFJ, 오른손잡이, 아이폰 유저
  • 좋아하는 음식 : 햄버거피자치킨솥뚜껑삼겹살떡볶이오튀김밥
  • 취미 : 개발, Programming, 코딩, 프로그래밍, Coding

🥷기술
Unity
Godot
Cpp
Javascript
D3
Vue

🐱 우리집 고양이 소개
츄르 먹은 후 츄르 먹기 전
  • 이름 : 콜라
  • 나이 : 8살
  • 종 : Nado moreum

📱 개인 프로젝트
🏢 참여한 프로젝트
빌런즈 Life is Pair 도씨어부키우기 직장상사혼내주기 서바이벌빙고 SlitherCoin

🌱 내 잔디밭

싱글톤 줄이기 본문

글 묶음/내 밥줄 Unity, C#

싱글톤 줄이기

초긍정 개발자 다빈맨 2023. 9. 27. 01:18

| 싱글톤의 단점

싱글톤 패턴(Singleton Pattern)은 하나의 인스턴스를 보장하면서 전역적인 접근을 가능하게 하는 편리한 이점 때문에 쉽게 사용되는 디자인 패턴 중 하나입니다. 그만큼 남용되기 쉬운 패턴이다 보니 사용에 주의가 필요합니다.
 
먼저 싱글톤 패턴이 문제 될 수 있는 부분을 정리해 보았습니다.
 
1. 전역 접근으로 인한 값의 수정을 추적이 어렵다.
 
전역 접근이 가능하기 때문에 static 변수가 가지는 단점도 그대로 가져옵니다. 그중 대표적으로 어디서든 상태를 변경할 수 있다는 것인데 외부의 이곳저곳에서 접근해 값을 수정하게 되면 점점 동료 개발자와의 거리도 멀어지게 됩니다.
 
2. 강한 커플링이 생긴다.
 
싱글인 주제에 커플링이 생긴다니 벌써 좀 이상하죠? 클래스 간의 결합력이 강하면 커플링(Coupling)이 강하다고 표현합니다. 보통 싱글톤은 여러 다른 클래스에서 직접 접근합니다. 그러다 보니 싱글톤의 구현이 수정되면 싱글톤을 참조하는 다른 클래스에도 영향이 갈 여지가 많습니다. 이미 작성된 코드가 다른 클래스에 의해 영향이 가서 최종적으로 수정까지 된다면 코드의 안정성을 매번 재검증하는 불안정한 코드가 됩니다. 또한 결합도가 높을수록 독립적인 테스트도 어려워지죠.
 
3. SOLID 원칙에 위배된다.
 
SOLID 원칙이란 올바르게 객체 지향을 설계하는 원칙 5가지를 의미하는데, 싱글톤 패턴은 이를 위반하기 쉬운 형태를 가집니다. 쉽게 위반될 수 있는 원칙 몇가지만 살펴봅시다.
 
3-1. 단일 책임 원칙 (Single Responsibility principle) 위반
 
단일 객체가 너무 많은 행동을 하도록 설계하는 경우가 많습니다. 이는 하나의 클래스가 본인 책임만을 갖도록 하는 단일 책임 원칙을 위반합니다.
 
3-2. 개방 폐쇄 원칙 (Open-Closed principle) 위반
 
추상화를 통해 확장에는 열려 있고, 느슨한 결합으로 특정 참조로 인해 수정되는 것에 대해서는 닫혀 있어야 함을 의미하는 원칙입니다. 위에서 이야기했듯이 클래스들끼리 강한 커플링이 생기면서 개방 폐쇄 원칙을 위반합니다.
 
3-3. 의존관계 역전 (Dependency inversion) 위반
 
느슨한 결합을 위해 보통 인터페이스나 abstract 클래스와 같이 더 추상화된 상위 클래스를 참조하도록 설계하는 원칙입니다. 자주 변경되는 구현체에 의존하지 않는 것을 의도하는 원칙이지만 싱글톤은 추상화되지 않은 실제 구현체에 접근하기 때문에 이 또한 위배됩니다. 
 
하지만 싱글톤을 설계할 때 애초에 의존관계를 역전 시키려는 의도가 없는 경우가 대부분이긴 합니다. 의도한다면 싱글톤을 상속해서 설계하는 기법을 이용한다면 어느정도 DI를 만족할 수 있기는 합니다. 


| 싱글톤 줄이기

이러한 문제를 체감하기 까지는 프로젝트에 따라 오랜 기간이 걸릴 수 있습니다. 그렇다면 우리가 당장 할 수 있는 건 싱글톤 사용을 줄이거나 혹은 조금 더 올바르게 사용하거나 아예 다른 방법으로 목적을 달성하는 것입니다. 
 
그렇다면 우리가 싱글톤을 왜 쓰는지 부터 다시 생각해봐야 합니다. 남용되는 싱글톤들은 처음부터 싱글톤일 필요가 없는 경우가 대부분 입니다. 진행중인 프로젝트에서 Controller, Manager 등등 여러가지 이름을 가지지만 결국 싱글톤인 경우가 많습니다. 만약 static 으로 설계가 가능한 클래스라면 차라리 static 클래스로 설계하는것이 낫습니다.

| 싱글톤 대안

이 글은 "싱글톤을 쓰지 말자"는 결론으로 도달하기 위한 글이 아닙니다. 싱글톤은 오랜 기간 많은 개발자에게 사랑받은 디자인 패턴으로 자리 잡았습니다. 당연하게도 그런 관심을 근거로 한 장점도 명확합니다. 다만 위에서 나열한 단점을 피하기 위한 목적으로 적용 가능한 대안을 제시합니다.
 
필요한 객체는 최대한 외부에서 전달받기
 
예를 들어 A, B, C 객체에서 공통적으로 참조하는 D 객체가 있다면, A, B, C 객체의 생성자 혹은 초기화 코드를 호출할 때, D 객체를 인수로 전달하는 방식입니다. 조금 귀찮지만 가장 확실한 방법입니다. 아래는 좀 더 구체화된 코드 예시입니다.

// 싱글톤 대신 그냥 클래스로 설계
public class EnemyController 
{
   ...
}

public class Player
{
    // 생성자로 그냥 받자
    public void Player(EnemyController enemyController)
    {
    	...
    }
}

이렇게 외부에서 객체를 '주입'해 주는 방식은 위에서 언급한 SOLID 원칙 중 의존관계 역전(DI)에 무게를 두고 설계해야 좋습니다. 관련된 내용은 여기서 다루지 않습니다.
 
서비스 중개자(Service Locator) 패턴 사용하기
 
서비스 중개자 패턴이란 위에서 예시로 보여준 객체를 전달(주입)해주는 작업을 담당하는 중개자 클래스를 따로 설계하는 는 패턴입니다. 여기서 서비스라는 단어는 주로 어디서든 요구될 수 있는 전역 수준의 객체를 '서비스' 라고 부를 수 있기 때문에 지어진 이름입니다.
 
예를들어 어디서든 접근 가능할 법한 서비스로 Audio 클래스를 예시로 간단하게 설계한 서비스와 서비스 중개자 클래스는 다음과 같습니다.

// 추상화된 서비스 클래스
public interface IAudioService
{
    void Play(string audio);
    void Mute();
}

// 서비스 구현체
public class AudioService : IAudioService
{
    void Play(string audio) { ... }
    void Mute() { ... }
}

public class ServiceLocator
{
    private static IAudioService _audioService;
    
    public static IAudioService AudioService => _audioService;

    public static void InitWithProvide()
    {
        _audioService = new AudioSystem();	
    }
}

그리고 서비스 중개자로 부터 서비스에 접근하는 클래스의 구현의 예시입니다. 

public class Player
{
    public void Attack()
    {
        ... 생략
        ServiceLocator.AudioService.Play("atk_audio.mp4");
    }
}

서비스를 인터페이스로 추상화 했기 때문에, DI 원칙을 준수하면서 서비스를 초기화 하는 시점을 직접 결정할 수 있어서 싱글톤이 가진 여러 단점에 대한 대안으로 많이 사용되는 패턴입니다.
 
위에서 언급한 내용이 흥미로웠다면 IoC Container 라는 키워드를 더 찾아보시는걸 추천드립니다.


| 유니티에서의 싱글톤 사용시 주의사항

유니티에서는 기존에 우리가 익히 많이 접한 싱글톤 패턴과 별개로 유니티 라이프 사이클에서 동작하는 Monobehavior를 상속하여 구현된 싱글톤을 사용하는 경우가 많습니다. Monobehavior를 상속해서 싱글톤을 구현하는 방식은 크게 두가지 정도로 구분됩니다.
 
1. 씬에 미리 배치되어 Awake에서 스태틱 멤버에 this 를 대입해서 초기화하는 싱글톤

public SceneSingleton<T> : Monobehavior
{
    private static T _instance;
    public static T Instance => _instance;
    
    public void Awake()
    {
        if (_instance == null)
        {
            _instance = this;
            DontDestroyOnLoad(this.gameObject);
        }
        else
        {
            Destroy(this.gameObject);
        }
    }
}

2. 씬에 미리 배치되지 않고 인스턴스에 접근할 때 존재하지 않으면 그때 동적으로 게임 오브젝트를 생성하는 싱글톤

public class Singleton<T> : Monobehavior
{
    private static T instance = null;
    public static T Instance
    {
        get
        {
            if (instance == null)
            {
                var objs = FindObjectsOfType<T>();

                if (objs.Length > 0)
                    instance = objs[0];

                if (instance == null)
                {
                    var name = typeof(T).FullName;
                    var gameObject = GameObject.Find(name);

                    if (gameObject == null)
                    {
                    	// 타입 이름과 일치하는 프리팹을 찾아 생성하는 방식
                        var prefab = Resources.Load<T>($"Prefabs/{name}");
                        instance = Instantiate(prefab, new Vector3(0, 0, 0), Quaternion.identity);
                    }
                }
                return instance;
            }
        }
    }
}

 
각 설계 방식에 따라 많이 접하는 실수를 살펴보겠습니다.
 
1번 방식의 싱글톤의 경우 Awake에서 멤버에 this가 대입되기 때문에 동일한 씬에 배치된 다른 게임 오브젝트의 Awake에서 해당 싱글톤을 접근하면 예상치 못한 이슈가 발생할 수 있으니 위험합니다. Start 이후 라이프 사이클에서만 싱글톤에 접근하도록 의식해야 합니다. 
 
2번 방식의 싱글톤은 동적으로 생성되어야 하는 싱글톤 스크립트는 프리팹에 부착되어 완전히 독립적으로(프리팹 하위 게임 오브젝트만 참조하도록) 어느 시점에 생성되어도 동작하도록 설계합니다. 이 싱글톤은 씬에 미리 배치되는 1번 방식의 싱글톤과 다르게 초기화 시점을 제어할 수 있다는 장점이 있기 때문에 가능하면 초기화 씬이나 프로세스를 따로 두고 원하는 초기화 순서로 싱글톤에 접근하여 초기화 하면 항상 접근 가능한 싱글톤에 대한 안정성을 보장받을 수 있습니다.


| 참고

한빛미디어 - 게임 프로그래밍 패턴 (로버트 나이스트롬)
[GOF] 싱글톤(Singleton) 패턴 - 꼼꼼하게 알아보자
SOLID (객체 지향 설계) - 위키백과
 
※ 글에서 작성된 코드는 일부분이 생략되거나 컴파일 없이 작성되어 그대로 사용하면 에러가 발생할 수 있습니다.