게임 로고 이미지

싱글톤 패턴은 게임 개발에서 많이 사용되는 디자인 패턴 중 하나입니다. 잘 사용하면 유용한 패턴이지만 잘못 사용할 경우에 확장성이 전혀 없는 스파게티 코드를 만들 수 있기에 주의하면서 사용 해야 합니다. 이번 글의 목적은 하나의 기능을 수정하기 위해서 관련된 모든 코드들을 수정 해야 하는 상황을 만들지 않기 위해서 싱글톤 패턴을 제대로 알고 사용하기 위한 글입니다.


[무엇인가?]

클래스를 만들 경우 여러 개체를 만드는 경우가 대부분 이지만 싱글톤 패턴은 하나의 개체만을 사용합니다. 이것을 다른 말로 클래스의 인스턴스를 하나로 보장한다고 합니다.

무엇인지 글로 보는 것보다 코드를 확인하는 것이 좋기에 예제 코드를 작성해 보겠습니다.

using UnityEngine;

public class GameManager : MonoBehaviour
{
    private static GameManager instance;

    // 게임 매니저의 인스턴스를 반환하는 정적 메서드
    public static GameManager Instance
    {
        get
        {
            if (instance == null)
            {
                instance = FindObjectOfType<GameManager>();

                if (instance == null)
                {
                    GameObject singleton = new GameObject("GameManagerSingleton");
                    instance = singleton.AddComponent<GameManager>();
                }
            }
            return instance;
        }
    }

    // 게임의 초기화 로직 등을 여기에 작성
    private void Awake()
    {
        if (instance == null)
        {
            instance = this;
            DontDestroyOnLoad(gameObject); // 씬 전환 시에도 유지되도록 설정
        }
        else
        {
            Destroy(gameObject);
        }
    }

    // 게임 매니저의 기능 예시
    public void StartGame()
    {
        Debug.Log("게임을 시작합니다!");
    }

    public void EndGame()
    {
        Debug.Log("게임을 종료합니다!");
    }
}

static은 클래스의 모든 인스턴스가 해당 변수를 공유하는 특징을 가지는데 이러한 특성을 이용하여 클래스에서 단 하나의 인스턴스만 갖게 하도록 하는 것 입니다. 만드는 방법을 알았으니 실제로 사용하는 방법을 알아 보겠습니다.

using UnityEngine;

public class PlayerController : MonoBehaviour
{
    private void Update()
    {
        // 플레이어의 움직임 관련 로직

        // 게임 매니저를 활용하여 게임 종료 체크
        if (Input.GetKeyDown(KeyCode.Escape))
        {
            GameManager.Instance.EndGame();
        }
    }
}

플레이어가 ESC키를 눌렀을 때 게임을 종료하는 코드 입니다. 이와 같이 클래스의 인스턴스를 단 하나만 존재 하도록 해서 여러 개체에 쉽게 접근하여 필요한 기능들을 바로 사용할 수 있습니다.

여기까지 이해 하셨다면 필요한 기능에 접근할 때 고민하지 말고 싱글톤 패턴을 사용하면 빠르게 개발을 할 수 있을 것 같다는 생각이 드실 수도 있는데, 잘못된 생각입니다. 장점과 단점을 살펴보면서 설명하겠습니다.


[장점]

  1. 위에서 말했듯이 여러 곳에서 쉽게 싱글톤 패턴을 사용한 클래스의 기능들을 사용 할 수 있습니다.
  2. 단 하나의 인스턴스만 필요한 경우 리소스의 낭비를 최소화 할 수 있습니다. 예시로 게임매니저의 같은 경우 하나의 게임을 관리하는 역할을 하는데 여러 개가 있을 필요가 없는 클래스 입니다.
  3. 중복 코드를 제거할 수 있습니다. 여러 곳에서 동일한 기능을 사용할 경우에 유용합니다. 예제 코드를 작성해 보겠습니다.
using UnityEngine;

public class PlayerSpawner : MonoBehaviour
{
    private GameObject playerPrefab;

    private void Start()
    {
        LoadResources();
    }

    private void LoadResources()
    {
        // Player 프리팹 로딩
        playerPrefab = Resources.Load<GameObject>("PlayerPrefab");
        Instantiate(playerPrefab, Vector3.zero, Quaternion.identity);
    }
}
using UnityEngine;

public class EnemySpawner : MonoBehaviour
{
    private GameObject enemyPrefab;

    private void Start()
    {
        LoadResources();
    }

    private void LoadResources()
    {
        // Enemy 프리팹 로딩
        enemyPrefab = Resources.Load<GameObject>("EnemyPrefab");
        Instantiate(enemyPrefab, new Vector3(10f, 0f, 0f), Quaternion.identity);
    }
}

플레이어와 적의 스포너에서 프리펩을 생성하고 싶을 때 싱글톤을 사용하지 않으면 위와 같이 중복으로 Instantiate메서드를 사용하게 됩니다. 이제 사용했을 경우를 보여드리겠습니다.

using UnityEngine;

public class ResourceManager : MonoBehaviour
{
    private static ResourceManager instance;

    public static ResourceManagerWithSingleton Instance
    {
        get
        {
            if (instance == null)
            {
                instance = FindObjectOfType<ResourceManagerWithSingleton>();
            }
            return instance;
        }
    }

    private void LoadResources(string name)
    {
        playerPrefab = Resources.Load<GameObject>(name);
        Instantiate(playerPrefab, Vector3.zero, Quaternion.identity);
    }
}
using UnityEngine;

public class EnemySpawnerWithSingleton : MonoBehaviour
{
    private GameObject enemyPrefab;

    private void Start()
    {
        ResourceManager.Instance.LoadResources("EnemyPrefab");
    }
}

핵심은 ResourceManager.Instance.LoadResources(“EnemyPrefab”); 부분 입니다. 만약 여러 스포너가 있다면 해당 메서드를 사용하여 여러 곳에서 사용이 가능 하므로 중복 코드를 제거할 수 있는 것 입니다.

4. 확장성이 증가합니다. 중복코드가 사라지는 장점과 관련성이 있습니다. 위 코드에서 LoadResources메서드를 여러 곳에서 선언하여 사용할 경우 기능을 추가하고 싶을 때 스포너의 개수 만큼 추가해줘야 합니다. 하지만 싱글톤 패턴을 사용할 경우 하나의 LoadResources만 존재하므로 기능 추가도 한곳에만 하면 되니 확장성이 증가하게 됩니다.

위와 같은 매력적인 장점들이 있음에도 주의해서 사용하지 않으면 프로젝트의 개발일정을 늦추는데 큰 역할을 하게 됩니다. 왜 그런지 단점들을 살펴보며 얘기하겠습니다.


[단점]

  1. 어떤 곳이든지 쉽게 접근이 가능해지기에 의존성이 높아질 가능성이 있습니다. 예시로 StageManager안에 Player가 선언돼 있는 상황을 가정해 보겠습니다.
public class StageManager
{
    private static StageManager instance;

    public Player CurrentPlayer;

    public static StageManager Instance
    {
        get
        {
            if (instance == null)
            {
                instance = new StageManager();
            }
            return instance;
        }
    }

    private StageManager()
    {
        currentPlayer = new Player();
    }

    public void StartStage()
    {
        currentPlayer.Initialize();
        // 스테이지 시작 로직
    }
}

public class Player
{
    private int health;

    public void Initialize()
    {
        health = 100;
    }

    public void TakeDamage(int damage)
    {
        health -= damage;
        Debug.Log("Player Health: " + health);
    }
}

위와 같이 선언돼 있을 경우에 Player는 어디서든 접근이 가능해 집니다. 만약 여러 곳에서 Player를 사용 했을 경우 의존성이 생기게 되고 그로 인해서 Player의 내부를 수정하거나 기능을 추가할 때 관련된 코드들을 전부 봐야하는 경우도 생기게 됩니다.

2. 테스트가 어려워집니다. DataManager를 실사용이 아닌 테스트용으로 바꿔서 여러 테스트를 해보고 싶다고 가정했을 때, 이미 코드 여러 곳에 퍼져 있는 상태라 어려움을 겪을 수 있습니다.

3. 메모리를 계속 차지하고 있습니다. static을 이용한 패턴이기 때문에 런타임 동안 메모리를 계속 차지하고 있기에 많은 instance를 생성하게 되면 성능 저하의 원인이 될 수 있습니다.

핵심은 잘못사용 하면 스파게티 코드가 되어 유지보수가 어려운 코드가 된다는 것 입니다. 이제부터는 알맞게 사용하는 방법이 무엇인지 알아보겠습니다.


[싱글톤 패턴 알맞게 사용하기]

알맞게 사용하려면 의존성 주입이나, 늦은 초기화 등등 여러가지를 고려 해야겠지만 처음 접하는 입장에서는 이 많은 것을 고려해서 코드를 작성하기 힘드실 겁니다. 그렇기 때문에 가장 핵심적인 사항만 다루겠습니다.

사용하기 전에 대체재가 없는지 생각해보는 것이 중요합니다. 플레이어가 적에게 데미지를 주는 상황을 예시로 들어 보겠습니다.

public class Player
{
    public int health = 100;

    public void TakeDamage(int damage)
    {
        health -= damage;
        Debug.Log("Player Health: " + health);
    }
}

public class Enemy
{
    private Player player;

    public Enemy()
    {
        player = PlayerManager.Instance.GetPlayer();
    }

    public void AttackPlayer(int damage)
    {
        player.TakeDamage(damage);
    }
}

public class PlayerManager
{
    private static PlayerManager instance;

    public static PlayerManager Instance
    {
        get
        {
            if (instance == null)
            {
                instance = new PlayerManager();
            }
            return instance;
        }
    }

    public Player GetPlayer()
    {
        return new Player();
    }
}

위 코드처럼 PlayerManager를 만들어서 Enemy가 직접 접근을 하게 할 필요는 없습니다. 해당 Manager클래스는 불필요한 클래스 입니다. 대체재로 작성한 코드를 보여드리겠습니다.

public class Player
{
    public int health = 100;

    public void TakeDamage(int damage)
    {
        health -= damage;
        Debug.Log("Player Health: " + health);
    }
}

public class Enemy
{
    private Player player;

    public Enemy(Player player)
    {
        this.player = player;
    }

    public void AttackPlayer(int damage)
    {
        player.TakeDamage(damage);
    }
}

public class Stage: MonoBehaviour
{
    private Player player;

    private void Start()
    {
        player = new Player();
        Enemy enemy = new Enemy(player);

        // Enemy가 Player에게 데미지 줌
        enemy.AttackPlayer(20);
    }
}

예시 코드이기에 좋은 구조는 아닙니다만, Stage클래스에서 Enemy가 Player를 파라미터로 받아 데미지를 주는 코드로 변경 되면서 의존성도 낮아지고 쓸데 없는 PlayerManager가 사라졌습니다. 이와 같이 파라미터로 넘겨 줄 수 있거나 이벤트 시스템을 사용하여 남용을 방지 할 수 있습니다.


[마지막으로]

싱글톤 패턴은 개념도 쉽고 사용자체도 쉬워서 남용하기 좋은 패턴입니다. 하지만 조금만 공부해 보면 사용하기 까다롭다고 느껴 집니다. 그렇기에 처음에는 사용하기 전에 고민이 많이 되실 텐데, 경험해 보지 않으면 잘 사용 하는 방법은 터득하기 어렵습니다.

그렇기에 사용하기 전에 대체재는 없을지 짧게 고민하신 후 사용하다가 문제가 발생하면 그것을 해결하는 과정에서 사용법을 터득하시게 될 것 입니다. 그러니 고민을 깊이 해서 많은 시간을 사용하기 보다는 많이 사용하여 경험을 쌓는 게 중요하다고 생각합니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다