Singleton Pattern, 유니티 Event, 오브젝트 풀링

감사콩·2025년 10월 20일

유니티

목록 보기
6/29

서론

싱글톤, 유니티 이벤트를 알아보고
기존에 제작했던 에너미 스포너부터 시작해서
전부 ObjectManager.cs가 관리하도록 개편 예정

Singletone Pattern

프로그램 전체에서 단 하나의 인스턴스만 존재하도록 보장하고
어디서든 접근할 수 있게 하는 디자인 패턴.

특징

  • 인스턴스 하나만 생성(유일성)
  • static 변수를 통해 어디서든 접근 가능(접근성)
  • 데이터 보관 및 공유 유용
  • 필요할 때 자동생성, 존재 시 추가 생성 X
  • 게임의 관리자 클래스 등에 활용

활용

  • 씬이 바뀌어도 유지되어야 하는 정보 (플레이어 점수, 설정값 등)
  • 전역적으로 접근해야 하는 시스템 (사운드, 데이터, UI 등)

이런 경우 new, Instantiate 사용 시, 매번 다른 인스턴스가 생기지만
싱글톤은 전역적으로 단 하나만 유지되므로 불필요한 중복 생성을 방지할 수 있음

단점

  • 의존성 증가: 여러 클래스가 싱글톤을 직접 참조하면 결합도가 높아짐
  • 테스트 어려움: 인스턴스가 고정되어 단위 테스트에 불리
  • 메모리 관리 어려움: 삭제를 원할 때 씬 전환에 맞춰 Destroy 필수

예시

GameScore 등의 활용이 가능하다

Unity Event

UnityEngine.Events 네임스페이스에 정의된 시리얼라이즈 가능한 이벤트 클래스.
인스펙터에서 직접 연결 가능하다.

우선 C# 과정에서 배운 delegate, event에 대해 간단히 복습해보고
유니티에서의 이벤트와 어떤 차이가 있나 알아보겠다.

delegate

메서드를 변수처럼 저장하고 호출할 수 있는 타입
public delegate void OnPlayerDie();

Invoke() 를 통해 연결된 함수를 호출

event

델리게이트를 한정해서 외부에서 직접 호출할 수 없도록 보호하는 키워드
배웠던 대로 외부에서 +=, -= 연산자를 통한 포함, 제외만 허용하고
invoke, = 대입을 사용하는 걸 막는 기능

여기까지 알아보았으니 유니티 이벤트와의 차이를 정리해보자.

개념 정리

개념핵심 키워드사용 목적
delegate함수 참조, 콜백메서드 연결
event안전한 알림델리게이트 보호
UnityEvent시리얼라이즈, 인스펙터 연결시각적 이벤트 관리



기존 코드 개선

4~5일차에 제작한 EnemySpawner.cs 및 불릿 생성 메서드는
수많은 객체를 Instantiate, Destroy 하며 생성되기에
해당 과정에서 불필요한 GC 부하가 생기고 있다.

이 부하를 없앨 방식으로 오브젝트 풀링 기법을 사용하며

추가로 모든 오브젝트를 관리할 중앙 집중형 오브젝트 관리 시스템을 구축해보겠다.
일단 오브젝트 풀링에 대해 먼저 알아보도록 하겠다.

오브젝트 풀링 기법

오브젝트를 필요할 때마다 생성, 파괴하는 방식이 아닌
미리 일정 개수를 만들어 보관해두고 필요에 따라 꺼내 쓰고
다시 돌려놓는 방식으로, GC 부하를 최소화하기 위해 사용한다.

일단 ObjectManager와 추후 GameManager에게 상속해줄 싱글톤 제네릭 클래스를 제작해보겠다.

1. 인스턴스 생성

//제네릭, T는 MonoBehaviour를 상속받는 클래스 로 제한.
public class Singleton<T> : MonoBehaviour where T : MonoBehaviour
{
    private static T _instance;

    //외부 호출용 프로퍼티, 해당 타입의 싱글톤이 없으면 찾아보고, 없을 시 새로 생성 후 설정
    public static T Instance
    {
        get
        {
        	//아직 인스턴스 할당이 되어있지 않을 시
            if (_instance == null)
            {
            	//씬에서 해당 타입의 객체를 찾기
                _instance = FindObjectOfType<T>();
                
                //여전히 null이면? 
                if (_instance == null)
                {
                    //T 타입의 이름을 가진 새 게임오브젝트를 생성
                    GameObject singletoneObj = new GameObject();
                    singletoneObj.name = typeof(T).ToString();
                    
                    //컴포넌트 추가, 인스턴스로 설정
                    _instance = singletoneObj.AddComponent<T>();

                }
            }

            return _instance;
        }
    }

2. 중복체크 및 연결 기능 구현

    protected virtual void Awake()
    {
        if (_instance == null)
        {
            _instance = this as T;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
        	//다른 인스턴스가 존재 시, 현재 인스턴스 파괴(중복방지)
            if (_instance != this)
            {
                Destroy(gameObject);
            }
        }
        
    }

이제 해당 클래스를 상속받을 ObjectManager.cs를 만들어보겠다.

ObjectManager

일단 에너미 프리펫부터 관리하도록 만들고
여기엔 오브젝트 Get, Return하도록 역할을 부여하겠다.

Singleton<T> 클래스의 상속을 받도록 제작
public class ObjectManager : Singleton<ObjectManager>

일단 오브젝트 풀링 기법을 사용해야 하는데
아직 잘 모르겠어서 예시를 검색해보았다.

필요한 메서드가 어떤 건지 알아냈으니
예시 코드를 참고해서 내 코드를 작성해보자.

1. InitializeEnemyPool()

일단 기본 변수 선언과
에너미를 생성하고 Pool에 담는 메서드부터 제작.

근데 이렇게 되면 다른 종류의 오브젝트를 만들 때마다 계속 추가가 필요하다.
확장성 면에서 좋은 방향성이 아닌 듯 하다.
일단 오브젝트 풀링 자체가 처음이니 이 방식으로 연습해보고
나중에 모든 오브젝트 종류를 총괄하는 범용적인 방식으로 제작해보자.

public class ObjectManager : Singleton<ObjectManager> //싱글톤 상속
{
    [SerializeField] private GameObject enemyPrefab; 

    //풀 사이즈 지정
    [SerializeField] private int initialPoolSize = 20; 

	//에너미 넣을 Queue 생성
    private Queue<GameObject> enemyPool = new Queue<GameObject>();

    private void InitializeEnemyPool()
    {
        if (enemyPrefab == null)
        {
            return;
        }

        //에너미 풀 사이즈만큼 생성해서 Enqueue
        for (int i = 0; i < initialPoolSize; i++)
        {
            GameObject newEnemy = Instantiate(enemyPrefab, transform);
            newEnemy.SetActive(false);
            enemyPool.Enqueue(newEnemy);
        }
    }
}

2. GetEnemy()

EnemySpawner.cs 쪽에서 오브젝트를 요청하는 용도의 함수

    //에너미스포너가  사용할 함수
    public GameObject GetEnemy()
    {
        GameObject enemyToSpawn;

        //저장해둔 Enemy를 Dequeue
        if (enemyPool.Count > 0)
        {
            enemyToSpawn = enemyPool.Dequeue();
        }
        else
        {
            // 풀이 비었으면 null 반환
            return null;
        }
        //보내기 전에 액티브 전환
        enemyToSpawn.SetActive(true);
        return enemyToSpawn;
    }

3. ReturnEnemy()

에너미를 풀로 다시 반환받는 함수

public void ReturnEnemy(GameObject enemy)
    {
        // ObjectManager의 풀로 반환
        enemy.SetActive(false);
        enemyPool.Enqueue(enemy);
    }

이게 맞는 건지 잘 모르겠다.

EnemySpawner.cs 도 수정해야된다

EnemySpawner.cs

  1. 프리펩 제거(오브젝트매니저로 이관)

  1. enemyPrefab을 이관했으니 아래 들어갈 조건문 대신할 것이 필요

기존 위치에 제작했는데
굳이 코루틴이 오류 조건까지 체크해야할 이유가 있을까 싶어서 밖으로 빼봤다

스타트 시 체크하도록 변경하고 통과 시, 적 생성 코루틴 스타트.

  1. EnemySpawn() 인스턴스 생성 변경

GetEnemy() 를 통해 오브젝트매니저의 풀에서 Dequeue해서 정보를 가져옴

Enemy.cs

마지막으로 Enemy.cs의 적 사망 로직 변경
위와 마찬가지로 Destroy() 부분을 ReturnEnemy()로 변경

이러면 추후에 Hp값을 설정 시
Hp가 감소한 채로 오브젝트 풀로 Return 될 문제가 생길 거다

까먹지 않게 미리 추가해뒀음
오늘은 감기가 심해서 과제만 제출하고 꺼야겠고
내일은 시간 되면 Hp랑 불릿 데미지도 만들어보자...

에너미는 대충 끝난 듯 하다
구현 테스트 해보겠다

테스트

한 마리씩만 생성됨
하나 죽으면 바로 생성

스폰 딜레이를 낮춰도 동일한 문제 발생

로그를 이곳 저곳 달며 확인하다가 찾았다

처음 생성 로그가 떠야 되는데 안 뜨길래 오브젝트매니저를 확인해보니

에너미를 초기 생산하여 풀에 넣는 함수가 사용되지 않고 있었다.

기존 싱글톤.cs에 맞춰 오버라이드 Awake 제작

protected override void Awake() 
{
    base.Awake(); 
    InitializeEnemyPool();
}

0번부터 19번 개체 생성되어 스폰되는 거 확인했다.

이제 Bullet 오브젝트들도 오브젝트 풀링으로 변경해보자

Bullet.cs

오브젝트 풀링 쓸 건데
lifeTime 세팅을 이제 없애도 되지 않을까? 했는데

나중에 전방향으로 퍼지는 스킬 등을 구현할 때는
Net를 쓰기보단 이 방법이 나을 듯 해서 유지하기로 결정.

대신 수명이 다하면 다시 오브젝트 풀로 돌아가는 방식으로 구현.

OnEnable()에 넣으면 되겠다
현재 총알 수명도 여기서 초기화 해주면 될 듯.

추가로 수명체크 메서드 만들어서 업데이트마다 작동하도록 구현.

나머지 Destroy() 조건도 전부 ReturnBullet(gameObject)로 변경하고
오브젝트매니저에 에너미와 마찬가지로 함수 제작해보겠다.

그 전에 앞에 오브젝트매니저 인스턴스의 메서드임을 명시하지 않았기에 추가

ObjectManager 총알 추가

에너미 오브젝트랑 같은 방식으로 제작함

바로 테스트

저번 과제로 총구에 만들어 둔 Weapon.cs에서
불릿 인스턴스를 생성하고 있었다

이걸 변경하고 불릿풀 생성 디버그 로그도 출력해봐야겠다

변경 완료

디버그 로그 출력 추가 완료

다시 테스트

300개 생성 문제없는데
총구에서 발사가 안되고 오브젝트매니저 빈칸 위치에서 발사중
이건 비교적 쉽다

        bulletInstance.transform.position = firePoint.position;
        bulletInstance.transform.rotation = firePoint.rotation;

FireBullet() 실행 시 포지션을 총구로 변경

구현완료

저번에 과제 때문에 만든 Weapon.cs인데 이런 임시방편 대신 개선이 필요해보인다.

마무리

오늘 배운 개념은 복습이 절반이라 어려울 건 없었다.
다만 현재 코드에 문제점이 꽤 있는 듯 하여 추후 개선할 내용을 정리하며 마치겠다.

추가 개선 필요사항

  1. Weapon.cs 기능 추가

현재 불릿 정하는 것도 오브젝트 매니저에게 권한이 넘어간 상황이라
Weapon.cs에게 뭔가 특별한 기능을 넣어주면 좋을 듯?

  1. 오브젝트 매니저 재구성

앞으로 오브젝트 종류가 늘어날 때마다 이런 식으로 작성 시
불필요한 코드가 너무 많고 가독성이 떨어진다.

저번 콘솔 프로젝트 처럼
프리팹을 Key, 오브젝트풀Queue를 Value로 딕셔너리를 제작하여
리스트화 해야겠다.

이건 질문해보고 결정해야겠다.

profile
안녕하시와요

0개의 댓글