Carrot
본문 바로가기
Unity/멋쟁이사자처럼 부트캠프

[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(43일차) - 2D 슈팅 게임 (2) 디자인 패턴 적용

by 독기품은토끼 2025. 7. 17.
✅ 오늘의 학습 목표
1. 싱글톤 패턴 구현 / 제너릭 적용
2. 오브젝트풀 패턴 구현 - 배열, 리스트, 큐 방식
3. 전처리
4. 오브젝트풀 패턴 실습

 

1. 싱글톤 패턴 (Singleton Pattern)

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

 

1. 싱글톤의 기본 구조

public class SingletonEx3 : MonoBehaviour
{
    private static SingletonEx3 instance = new SingletonEx3(); // 내부 변수
    public static SingletonEx3 Instance // 프로퍼티
    {
        get
        {
            if (instance == null)
            {
                instance = new SingletonEx3();
            }

            return instance;
        }
    }
}

 

2. Unity에서의 싱글톤 구조

new 키워드를 사용하지 않는다.

 

▶ MonoBehaviour는 Unity 엔진이 관리하는 컴포넌트

  • MonoBehaviour는 GameObject에 붙여서 사용하는 컴포넌트
  • Unity는 new 키워드로 생성한 MonoBehaviour 인스턴스를 인식하지 못함
  • Unity 내부 시스템 (Update, Start, Inspector, Serialize 등)과 연동이 되지 않아 new 키워드를 사용하지 않음
using UnityEngine;

public class SingletonEx3 : MonoBehaviour
{
    public static SingletonEx3 Instance;

    void Awake()
    {
        if (Instance == null)
        {
            Instance = this;
            DontDestroyOnLoad(gameObject); // 필요하면 추가
        }
        else
        {
            Destroy(gameObject); // 중복 방지
        }
    }
}
  • Awake()에서 인스턴스를 설정하니까 new가 필요 없음
  • AddComponent<SingletonEx3>() ← Unity가 직접 만든 복제품. Unity가 모든 걸 연결

 

3. 제너릭

자료형(타입)을 매개변수로 받는 클래스나 메서드를 만들 수 있게 해주는 문법

즉, 다양한 타입에 대해 재사용 가능한 코드를 만들 수 있게 해줌

 

 

Unity로 게임을 만들다 보면 점수 관리, UI 관리, 게임 흐름 제어 같은 다양한 매니저 스크립트를 만들게 된다.
이런 매니저들은 보통 게임 전체에서 하나만 존재해야 하는 경우가 많다.
(예: 점수가 이중으로 올라간다거나, 게임 로직이 중복으로 실행되는 걸 막기 위해서)

그래서 자연스럽게 싱글톤 패턴을 사용하게 된다.

 

처음엔 아래와 같이 매니저마다 각각 싱글톤 코드를 따로 작성해 주었다.

 

그런데 이런 식으로 만들다 보면 매니저마다 구조는 거의 똑같고 살짝 내용을 수정해주려 하면 모든 싱글톤 내용을 수정해주어야 하는 번거로움이 생긴다.

이럴 경우에 사용해 주는 것이 바로 제너릭이다.


 

public class Singleton<T> : MonoBehaviour where T : Component

 

모든 컴포넌트 타입에 대해 싱글톤을 만들 수 있는 공통 클래스인 Singleton<T> 클래스를 생성해 주었다.

다른 매니저 스크립트도 해당 클래스를 상속하면 싱글톤 기능이 자동으로 적용된다.

 

T는 자료형(타입)을 매개변수로 받는 자리라고 보면 된다.
이렇게 하면 어떤 타입이 오더라도 같은 방식으로 싱글톤을 적용할 수 있다.

 

// 싱글톤 전체 코드
using UnityEngine;

public class Singleton<T> : MonoBehaviour where T : Component
{
    private static T instance;
    public static T Instance
    {
        get
        {
            if (instance == null)
            {
                var t = FindFirstObjectByType<T>();

                if (t != null)
                    instance = t;
                else
                {
                    var newObj = new GameObject(typeof(T).ToString());
                    newObj.AddComponent<T>();

                    instance = newObj.GetComponent<T>();
                }
            }
            return instance;
        }
    }

    protected virtual void Awake() // virtual : 선택적 재정의 / abstract : 강제적 재정의
    {
        if (instance == null)
        {
            instance = this as T;
            DontDestroyOnLoad(gameObject);
        }
        else
            Destroy(gameObject);
    }
}

 

  • FindFirstObjectByType<T>()를 통해 씬에 해당 타입의 오브젝트가 있는지 먼저 확인
  • 없으면 자동으로 GameObject를 생성하고 해당 컴포넌트를 붙여줌
  • Awake()는 virtual 키워드로 선택적으로 재정의 가능하게 해둠

 

 

[제너릭 싱글톤 적용 후]

using UnityEngine;

public class UIMnager : Singleton<UIMnager>
{
    protected override void Awake()
    {
        base.Awake();

        Debug.Log("추가할 기능");
    }
}

 

실제 매니저 스크립트에서 제너릭 싱글톤을 적용해 주면 코드가 이렇게 간결해진다.

필요한 기능만 Awake() 안에서 추가해 주면 된다.

 

4. ScoreManager 싱클톤 적용

using TMPro;
using UnityEngine;

public class ScoreManager : Singleton<ScoreManager>
{
    public TextMeshProUGUI currentScoreUI;
    public TextMeshProUGUI bestScoreUI;

    private int currentScore;
    private int bestScore;

    public int Score
    {
        get
        {
            return currentScore;
        }
        set
        {
            currentScore = value;

            currentScoreUI.text = "현재 점수 : " + currentScore;

            if (currentScore > bestScore)
            {
                bestScore = currentScore;
                bestScoreUI.text = "최고 점수 : " + bestScore;

                PlayerPrefs.SetInt("BestScore", bestScore);
            }
        }
    }

    void Start()
    {
        bestScore = PlayerPrefs.GetInt("BestScore", 0);

        bestScoreUI.text = "최고 점수 : " + bestScore;
    }
}

 

// Enemy 스크립트
private void OnCollisionEnter(Collision other)
{
    //// 점수 증가
    //GameObject smObject = GameObject.Find("ScoreManager");
    //ScoreManager sm = smObject.GetComponent<ScoreManager>();

    //// sm.SetScore(sm.GetScore() + 1); // 책에 적힌 거
    //var score = sm.GetScore() + 1;
    //sm.SetScore(score);

    ScoreManager.Instance.Score++;
}

 

이전처럼 Enemy 스크립트에서 ScoreManager의 GetScore()/SetScore() 호출할 필요 없이 Score 프로퍼티 하나로 끝

 

 

2. 오브젝트 풀 패턴 (Object Pool Pattern)

오브젝트를 미리 생성하여 Pool에 저장해 두고 재사용하는 패턴

 

1. 배열 (Array) 방식

public class PlayerFire_Array : MonoBehaviour
{
    public GameObject bulletFactory;
    public GameObject firePosition;

    public int poolSize = 10;
    public GameObject[] bulletObjectPool;

    void Start()
    {
        bulletObjectPool = new GameObject[poolSize];

        for (int i = 0; i < poolSize; i++)
        {
            GameObject bullet = Instantiate(bulletFactory);
            bulletObjectPool[i] = bullet;
            bullet.SetActive(false);
        }
    }

    void Update()
    {
        if (Input.GetButtonDown("Fire1"))
        {
            for (int i = 0; i < poolSize; i++)
            {
                GameObject bullet = bulletObjectPool[i];

                if (!bullet.activeSelf)
                {
                    bullet.SetActive(true);
                    bullet.transform.position = firePosition.transform.position;
                    break;
                }
            }
        }
    }
}

 

2. 리스트 (List) 방식

using System.Collections.Generic;

public class PlayerFire_List : MonoBehaviour
{
    public GameObject bulletFactory;
    public GameObject firePosition;

    public int poolSize = 10;
    public List<GameObject> bulletObjectPool;

    void Start()
    {
        bulletObjectPool = new List<GameObject>();

        for (int i = 0; i < poolSize; i++)
        {
            GameObject bullet = Instantiate(bulletFactory);
            bullet.SetActive(false);
            bulletObjectPool.Add(bullet);
        }
    }

    void Update()
    {
        if (Input.GetButtonDown("Fire1"))
        {
            if (bulletObjectPool.Count > 0)
            {
                GameObject bullet = bulletObjectPool[0];
                bulletObjectPool.RemoveAt(0);

                bullet.SetActive(true);
                bullet.transform.position = firePosition.transform.position;
            }
        }
    }
}

 

3. 큐 (Queue) 방식

using System.Collections.Generic;

public class PlayerFire_Queue : MonoBehaviour
{
    public GameObject bulletFactory;
    public GameObject firePosition;

    public int poolSize = 10;
    public Queue<GameObject> bulletObjectPool;

    void Start()
    {
        bulletObjectPool = new Queue<GameObject>();

        for (int i = 0; i < poolSize; i++)
        {
            GameObject bullet = Instantiate(bulletFactory);
            bullet.SetActive(false);
            bulletObjectPool.Enqueue(bullet);
        }
    }

    void Update()
    {
        if (Input.GetButtonDown("Fire1"))
        {
            if (bulletObjectPool.Count > 0)
            {
                GameObject bullet = bulletObjectPool.Dequeue();
                bullet.SetActive(true);
                bullet.transform.position = firePosition.transform.position;
            }
        }
    }
}

 

4. 차이점

방식 특징 장점 단점
배열 고정 크기 인덱스로 빠르게 접근 가능 재사용을 위해 비활성 체크 필요
리스트 유연한 크기 추가/삭제가 쉬움 앞에서 제거할 경우 성능 저하
FIFO 구조 구조적으로 탄탄하고 깔끔 큐에 재사용 안 하면 직접 Enqueue 필요

 

 

5. Enemy 스크립트 적용

using UnityEngine;

public class Enemy : MonoBehaviour
{    
    private void OnCollisionEnter(Collision other)
    {
        //// 점수 증가
        //GameObject smObject = GameObject.Find("ScoreManager");
        //ScoreManager sm = smObject.GetComponent<ScoreManager>();

        //// sm.SetScore(sm.GetScore() + 1); // 책에 적힌 거
        //var score = sm.GetScore() + 1;
        //sm.SetScore(score);

        ScoreManager.Instance.Score++;

        // 파티클 생성
        GameObject explosion = Instantiate(explosionFactory);
        explosion.transform.position = transform.position;

        // 파괴 기능
        // Destroy(other.gameObject);
        if (other.gameObject.name.Contains("Bullet"))
        {
            //PlayerFire player = GameObject.Find("Player").GetComponent<PlayerFire>();
            //player.bulletObjectPool.Add(other.gameObject); // pool 오브젝트를 제거해주었기 때문에 다시 추가해주어야 함
            // other.gameObject.SetActive(false); // 총알 오브젝트

            // 위 코드를 싱글톤/리스트로 구현
            // PlayerFire.Instance.bulletObjectPool.Add(other.gameObject);
            
            //큐
            PlayerFire.Instance.bulletObjectPool.Enqueue(other.gameObject);

            other.gameObject.SetActive(false);
        }
        else
            // Destroy(gameObject); // Enemy 오브젝트
            Destroy(other.gameObject); // 플레이어 오브젝트

        // EnemyManager.Instance.enemyObjectPool.Add(gameObject); // 리스트
        EnemyManager.Instance.enemyObjectPool.Enqueue(gameObject); // 큐
        gameObject.SetActive(false);
    }
}
using UnityEngine;

public class DestroyZone : MonoBehaviour
{
    private void OnTriggerEnter(Collider other)
    {
        if (other.gameObject.name.Contains("Bullet"))
        {
            // 리스트
            // PlayerFire.Instance.bulletObjectPool.Add(other.gameObject);

            // 큐
            PlayerFire.Instance.bulletObjectPool.Enqueue(other.gameObject);
            other.gameObject.SetActive(false);
        }
        else
        {
            // EnemyManager.Instance.enemyObjectPool.Add(other.gameObject);
            EnemyManager.Instance.enemyObjectPool.Enqueue(other.gameObject);
            other.gameObject.SetActive(false);
        }
    }
}

 

충돌 대상이 총알 또는 적일 경우, 해당 오브젝트를 비활성화한 뒤 큐에 다시 Enqueue 해서 오브젝트 풀에 반환한다. 이후 재사용할 수 있도록 대기 상태로 만든다.

 

  • PlayerFire.Instance.bulletObjectPool.Enqueue() → 총알을 풀에 복귀
  • EnemyManager.Instance.enemyObjectPool.Enqueue() → 적을 풀에 복귀
  • SetActive(false) → 오브젝트를 화면에서 사라지게 하고 비활성 상태로 전환

 

3. 전처리

어떤 작업이나 분석을 하기 전에, 필요한 재료나 데이터를 준비하거나 가공하는 과정

유니티에서는 PC 조작, 모바일 조작 같은 걸 전처리 작업을 통해 실행 방식을 달리 할 수 있다.

 

 

 

이미지와 같이 전처리 상수를 사용자가 직접 정의할 수 있다.

 

#define DEBUG_TEST

using System.Collections.Generic;
using UnityEngine;

public class PlayerFire : Singleton<PlayerFire>
{
    public GameObject bulletFactory;
    public GameObject firePosition;

    public int poolSize = 10;

    public Queue<GameObject> bulletObjectPool; // 큐

    void Start()
    {
        bulletObjectPool = new Queue<GameObject>();

        for (int i = 0; i < poolSize; i++)
        {
            GameObject bullet = Instantiate(bulletFactory);
            bulletObjectPool.Enqueue(bullet);
            bullet.SetActive(false);
        }
    }

    void Update()
    {
#if UNITY_STANDALONE ||UNITY_EDITOR || DEBUG_TEST
        if (Input.GetButtonDown("Fire1"))
        {
            if (bulletObjectPool.Count > 0)
            {
                GameObject bullet = bulletObjectPool.Dequeue();
                bullet.SetActive(true);
                bullet.transform.position = firePosition.transform.position;
            }
#elif UNITY_ANDROID || UNITY_IOS
        if (Input.GetTouch(0).phase == TouchPhase.Began)
        {
            Debug.Log("손가락 터치");

            if (bulletObjectPool.Count > 0)
            {
                GameObject bullet = bulletObjectPool.Dequeue();
                bullet.SetActive(true);
                bullet.transform.position = firePosition.transform.position;
            }
        }
#endif
        }
    }
}

 

▶ #if UNITY_STANDALONE || UNITY_EDITOR || DEBUG_TEST

  • 조건부 컴파일
  • Unity Standalone(PC 빌드), Unity Editor, 혹은 DEBUG_TEST 중 하나라도 true면 그 아래 코드를 컴파일해서 실행
  • 즉, PC에서 실행하거나 유니티 에디터에서 테스트하거나 DEBUG_TEST가 정의되어 있을 때는 이 조건 안의 코드가 동작
  • Input.GetButtonDown("Fire1")는 마우스 클릭(또는 키보드 입력)을 감지하는 코드니까 PC 기준 조작

▶ #elif UNITY_ANDROID || UNITY_IOS

  • 모바일 플랫폼일 때 실행되는 코드
  • 터치 입력(Input.GetTouch(0).phase)을 감지해서 모바일에서 조작 가능하게 함

▶ #endif

  • 조건부 컴파일 영역의 끝 표시

 

4. Unity 제공 ObjectPool 사용해 보기

🥕 예행 작업
1. Scene 생성 (Unity Object Pool)
2. Script 생성 (PoolItem, PoolManager)

 

이번에는 Unity에서 제공하는 ObjectPool을 활용해서 오브젝트 풀을 만들어보려고 한다.

이전처럼 직접 큐를 쓰는 방식보다 훨씬 깔끔하게 관리할 수 있다.

 

1. 오브젝트 풀 관리

using UnityEngine;
using UnityEngine.Pool;

public class PoolManager : MonoBehaviour
{
    public ObjectPool<GameObject> pool;
    public GameObject prefab;

    void Awake()
    {
        pool = new ObjectPool<GameObject>(CreateObject, OnGetObject, OnReleaseObject); // 생성 -> 꺼내쓰고 -> 집어넣는 기능 가능
    }

    private GameObject CreateObject()
    {
        GameObject obj = Instantiate(prefab);
        obj.SetActive(false);

        return obj;
    }

    private void OnGetObject(GameObject obj) // 꺼내는 기능
    {
        Rigidbody rb = obj.GetComponent<Rigidbody>();
        rb.linearVelocity = Vector3.zero;
        rb.angularVelocity = Vector3.zero;

        obj.transform.position = Vector3.zero;
        obj.SetActive(true);
    }

    private void OnReleaseObject(GameObject obj) // 집어 넣는 기능
    {
        obj.SetActive(false);
    }

    private void OnDestroyObject(GameObject obj) // 파괴하는 기능
    {
        Destroy(obj);
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            GameObject obj = pool.Get(); // Pool에서 오브젝트를 하나 꺼내는 방법
        }
    }
}

 

오브젝트 풀에 3가지 기능을 추가해 주었다.

 

  • CreateObject(): 풀에 들어갈 오브젝트를 처음 생성할 때 사용
    • Prefab을 하나 만들어서 비활성화 상태로 리턴
  • OnGetObject(): 오브젝트를 꺼낼 때 호출
    • 꺼낼 때에는 위치 초기화, Rigidbody 초기화, SetActive 활성화
  • OnReleaseObject(): 다시 풀로 돌려보낼 때 호출
    • 풀로 돌려보낼 땐 비활성화만 적용

스페이스바를 누르면 풀에서 오브젝트를 하나씩 꺼내도록 Update문 구현

 

2. 개별 오브젝트

using UnityEngine;

public class PoolItem : MonoBehaviour
{ 
    private PoolManager poolManager;
    private bool isInit = false;

    void Awake()
    {
        poolManager = GameObject.FindFirstObjectByType<PoolManager>();
    }

    void OnEnable()
    {
        if (!isInit)
            isInit = true;
        else
            Invoke("ReturnObject", 2f);
    }

    private void ReturnObject()
    {
        poolManager.pool.Release(gameObject);
    }
}

 

 

일정 시간 후에 자동으로 자기 자신을 다시 풀로 돌려보내도록 만든 코드

 

 

 

 


 

반성합니다.

저번주 결석 1회 조퇴 1회 외출 1회, 이번주 결석 1회

아니? 걍 1주일 동안 결석 2회 조퇴&외출 2회 함

예.. 요즘 갑자기 미룬이 게으름핑 아무것도 하기싫어핑이 도져서 많이 쉬다 왔습니다...

 

다시 정신차리고 열심히 임하겠습니다..