✅ 오늘의 학습 목표
1. 게임 디자인 패턴 (1)
1. 싱글 톤 (Singleton)
클래스를 하나의 인스턴스로만 존재하도록 만드는 패턴
- 다른 클래스에서 쉽게 접근 가능 → 코드 간결성 ⬆
- 유일성 보장
[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(43일차) - 2D 슈팅 게임 (2) 디자인 패턴 적용
✅ 오늘의 학습 목표1. 싱글톤 패턴 구현 / 제너릭 적용2. 오브젝트풀 패턴 구현 - 배열, 리스트, 큐 방식3. 전처리4. 오브젝트풀 패턴 실습 1. 싱글톤 패턴 (Singleton Pattern)프로그램 전체에서 단 하나
toxicbunny.tistory.com
싱글톤은 이전 포스팅에서 자세히 적어놓았으니 참고 바란다.
2. 오브젝트 풀 (Object Pool)
오브젝트를 미리 생성하여 Pool에 저장해 두고 재사용하는 패턴
1. 기본 사용법
using System.Collections.Generic;
using UnityEngine;
public class StudyObjectPool : StudyGenericSingleton<StudyObjectPool>
{
public Queue<GameObject> objQueue = new Queue<GameObject> (); // 오브젝트가 들어갈 풀
public GameObject objPrefab; // 생성될 오브젝트
public int poolSize = 100;
void Start()
{
CreateObject();
}
private void CreateObject()
{
for (int i = 0; i < poolSize; i++)
{
GameObject newObj = Instantiate(objPrefab, transform);
EnqueueObject(newObj);
}
}
public void EnqueueObject (GameObject obj) // 오브젝트를 풀장에 넣는 기능
{
objQueue.Enqueue(obj);
obj.SetActive(false); // 풀장에 들어갔으니 화면에 보이면 안되므로 false
}
public GameObject DequeueObject() // 오브젝트를 풀장에서 꺼내는 기능
{
GameObject obj = objQueue.Dequeue();
return obj;
}
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
if (objQueue.Count < 10)
CreateObject();
GameObject obj = DequeueObject(); // 풀에서 오브젝트를 꺼내 사용
obj.transform.SetPositionAndRotation(transform.position, Quaternion.identity);
}
// 생성된 오브젝트에서 사용하는 기능
// StudyObjectPool.Instance.EnqueueObject(gameObject);
}
}
자주 생성되고 파괴되는 오브젝트가 있다면 매번 Instantiate()와 Destroy()를 반복하는 대신
미리 만들어 둔 오브젝트들을 재사용하는 게 성능적으로 훨씬 유리하다.
- Start()에서 한 번만 실행되는 CreateObject()로 풀 초기 세팅을 해둔다.
- CreateObject()는 poolSize만큼 objPrefab을 만들어 큐에 저장한다.
- EnqueueObject()는 오브젝트를 다시 풀로 되돌리는 역할을 한다.
- DequeueObject()는 풀에서 하나 꺼내올 때 사용한다.
- Update()에서는 스페이스바를 누르면 하나 꺼내오고, 개수가 너무 줄어들면 다시 풀을 보충한다.
2. Unity에서 제공하는 ObjectPool 활용법
유니티에서는 제네릭 기반의 오브젝트 풀 시스템을 기본으로 제공하고 있다.
직접 큐(Queue)를 이용해서 오브젝트 풀을 만드는 것도 가능하지만 유니티가 제공하는 ObjectPool<T>를 활용하면 더 간결하고 관리 포인트가 줄어든다.
[생성자 매개변수]
ObjectPool<GameObject>.ObjectPool(
System.Func<GameObject> createFunc,
System.Action<GameObject> actionOnGet = null,
System.Action<GameObject> actionOnRelease = null,
System.Action<GameObject> actionOnDestroy = null,
bool collectionCheck = true,
int defaultCapacity = 10,
int maxSize = 10000
)
| 매개변수 | 설명 |
| createFunc | 새 오브젝트 생성 방법 지정 (필수) |
| actionOnGet | 꺼낼 때 행동 지정 |
| actionOnRelease | 다시 넣을 때 행동 지정 |
| actionOnDestroy | 완전히 파괴될 때 행동 지정 |
| collectionCheck | 중복 반환 체크 (디버깅용) |
| defaultCapacity | 초기 용량 |
| maxSize | 최대 용량 제한 |
using UnityEngine;
using UnityEngine.Pool;
public class StudyObjectPool2 : StudyGenericSingleton<StudyObjectPool2>
{
public ObjectPool<GameObject> objPool;
public GameObject objPrefab;
void Awake()
{
objPool = new ObjectPool<GameObject>(CreateObject, GetObject, ReleaseObject);
}
private GameObject CreateObject()
{
GameObject obj = Instantiate(objPrefab, transform);
obj.SetActive(false);
return obj;
}
private void GetObject(GameObject obj)
{
Debug.Log("풀에서 오브젝트 꺼내는 기능");
obj.SetActive(true);
}
private void ReleaseObject(GameObject obj)
{
obj.SetActive(false);
Debug.Log("풀에 오브젝트를 넣는 기능");
}
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
GameObject obj = objPool.Get();
}
// 생성한 오브젝트에서 사용하는 기능
// StudyObjectPool2.Instance.objPool.Release(gameObject);
}
}
직접 큐를 사용하는 방식은 개발자가 반환 여부를 직접 체크해야 하고 자칫하면 중복 반환이나 풀 누수 같은 문제가 생기기 쉽지만
유니티에서 제공하는 ObjectPool은 이런 것들을 내부적으로 자동으로 관리해주기 때문에 실수할 여지를 줄여준다.
- 중복 반환 체크 기능 내장
- 최대 풀 크기 제한 기능 제공
- 사용/반환 시 콜백 처리 가능
- 사용이 간편하고 안전성 향상
- GC 최소화 설계로 퍼포먼스에 유리
3. 상태 (State)
객체 자신의 내부 상태에 따라 행위를 변경하도록 하는 패턴
게임을 만들다 보면 오브젝트의 상태가 자주 바뀌는 경우가 많다.
예를 들어 플레이어의 경우
대기 상태(Idle) > 이동 상태(Move) > 공격 상태(Attack) 등등 상태마다 전혀 다른 동작을 하게 된다.
이럴 때 각 상태를 if-else나 switch문으로 관리하다보면 점점 코드가 복잡해지고 유지보수가 힘들어진다.
이럴 때 사용하는 것이 바로 상태 패턴이다.
public interface IState
{
void StateEnter();
void StateUpdate();
void StateExit();
}
우선 공통된 동작을 정의할 인터페이스를 만든다.
각 상태 클래스들은 이 IState 인터페이스를 상속해서
StateEnter, StateUpdate, StateExit 메서드를 반드시 구현하게 된다.
public class IdleState : IState { ... }
public class MoveState : IState { ... }
public class AttackState : IState { ... }
이렇게 상태마다 클래스를 분리해두면
각 클래스 안에서 해당 상태에 맞는 동작만 집중해서 구현할 수 있다.
(복잡한 조건문 없이도 깔끔하게 분리 가능)
using UnityEngine;
public class StudyState : MonoBehaviour
{
public IState state;
private IState idleState, moveState, attackState, jumpState;
void Awake()
{
idleState = gameObject.AddComponent<IdleState>();
moveState = gameObject.AddComponent<MoveState>();
attackState = gameObject.AddComponent<AttackState>();
jumpState = gameObject.AddComponent<JumpState>();
state = idleState;
}
void Start()
{
state.StateEnter();
}
void OnDestroy()
{
state.StateExit();
}
void Update()
{
state?.StateUpdate();
#region 기능 테스트
if (Input.GetKeyDown(KeyCode.Alpha1))
{
SetState(new IdleState());
}
else if (Input.GetKeyDown(KeyCode.Alpha2))
{
SetState(new MoveState());
}
else if (Input.GetKeyDown(KeyCode.Alpha3))
{
SetState(new AttackState());
}
else if (Input.GetKeyDown(KeyCode.Alpha4))
{
SetState(new JumpState());
}
#endregion
}
}
이 예시에서는 각 상태 클래스를 MonoBehaviour로 만들어 AddComponent를 통해 게임 오브젝트에 붙이는 방식으로 구현했다.
이렇게 하면 상태 클래스 안에서도 Transform, Animator, Coroutine 같은 Unity 기능들을 그대로 사용할 수 있다.
(일반 클래스에서는 Unity 관련 기능을 직접 쓰기 어려움)
🚨 주의할 점은 MonoBehaviour를 상속받고 있으므로 new IdleState()로 상태를 생성하면 Unity 기능이 작동하지 않는다.
AddComponent를 통해 Unity 오브젝트에 붙여줘야 Update, Coroutine, Transform 같은 기능을 정상적으로 쓸 수 있다.
public void SetState(IState newState)
{
if (state != newState)
{
state.StateExit(); // 상태 변경 전
state = newState; // 상태 변경
state.StateEnter(); // 상태 변경 후
}
}
현재 상태와 전환하려는 상태가 다를 때만 전환하도록 조건을 걸어줬다.
같은 상태를 중복 전환할 경우 예상치 못한 충돌이나 리소스 낭비가 생길 수 있기 때문에
이런 체크는 꼭 넣어주는 게 좋다.


두 스크린샷은
상태를 일반 클래스로 구현한 구조와 Monobehaviour 컴포넌트로 구현한 구조이다.
첫 번째 구조 - 일반 클래스로 구현
해당 구조는 Unity에 의존하지 않는 순수한 상태 클래스 구조이다.
재사용성과 테스트가 쉽고 Unity 기능을 최소한으로 사용하고 싶은 경우에 적합하다.
- 상태 클래스들이 MonoBehaviour를 상속하지 않음.
- StudyState가 state.StateEnter(this)처럼 MonoBehaviour를 넘겨줌.
- 코루틴이 필요하면 mono.StartCoroutine()으로 실행함.
두 번째 구조 - MonoBehaviour 컴포넌트로 구현
해당 구조는 Unity 생명 주기와 밀접하게 통합된 구조이다.
각 상태에서 직접 Unity 기능을 활용할 수 있다. (Coroutine, Transform 등)
스크립트 관리보다는 Unity 오브젝트 중심 설계에 가깝다.
- 상태 클래스들이 MonoBehaviour를 상속함.
- gameObject.AddComponent<State>()로 상태를 Unity 컴포넌트로 추가
- 각 상태 클래스에서 바로 StartCoroutine() 호출 가능
이렇게 동일한 상태 패턴도 Unity에 어떻게 녹여내느냐에 따라 다른 구조가 될 수 있다.
4. 이벤트 버스 (EventBus)
객체가 게시(Publiish)/구독(Subscribe)할 수 있는 전역 이벤트를 관리하는 중앙 허브 방식 패턴
- 이벤트 버스 패턴은 전역 이벤트 시스템을 만들어서 오브젝트 간 느슨한 결합(Loose Coupling)을 유지하면서 메시지를 주고받을 수 있도록 하는 디자인 패턴이다.
- 발신자(Sender)와 수신자(Receiver) 간의 직접적인 의존성을 없애고 이벤트를 중앙에서 관리하는 방식
쉽게 말하면 여러 스크립트에서 같은 이벤트를 처리해야 할 일이 많을 수 있다.
예를 들어, 플레이어가 점수를 얻으면
- 점수 UI를 업데이트해야 하고
- 기록 시스템에 저장해야 하고
- 특정 점수에 도달하면 업그레이드도 해줘야 한다.
이걸 각각의 스크립트에서 직접 호출하면 스크립트 간 의존성이 너무 강해지고 나중에 기능을 추가하거나 제거하기도 어려워진다.
이럴 때 쓰는 게 이벤트 버스 패턴이다.
public static class StudyEventBus
{
public static event Action onStart;
public static event Action<int> onScoreChanged;
public static void StartEvent()
{
onStart?.Invoke();
}
public static void ScoreChanged(int newScore)
{
onScoreChanged?.Invoke(newScore);
}
}
이벤트를 관리하는 중앙 통신소 같은 역할을 하는 클래스를 하나 만들어준다.
여기서 onScoreChanged라는 이벤트가 발생하면 구독하고 있는 모든 스크립트에 알림을 보낸다.
public class ScoreController : MonoBehaviour
{
public int score = 0;
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
score++;
StudyEventBus.ScoreChanged(score);
}
}
}
Space 키를 누르면 점수가 올라가고 그 정보를 이벤트 버스(중앙 허브)에 전달한다.
using UnityEngine;
namespace Pattern
{
public class ScoreManager : MonoBehaviour
{
void OnEnable()
{
StudyEventBus.onScoreChanged += UpdateScore;
}
void OnDisable()
{
StudyEventBus.onScoreChanged -= UpdateScore;
}
private void UpdateScore(int newScore)
{
Debug.Log($"현재 점수 : {newScore}");
}
}
}
이쪽에서는 점수가 바뀔 때마다 로그를 찍는다.
OnEnable에서 구독을 걸고 OnDisable에서 구독 해제하는 건 예상치 못한 메모리 누수나 중복 호출을 방지하기 위한 안전한 습관이다.
[전체 흐름]

이렇게 이벤트 버스 패턴은 여러 스크립트가 하나의 이벤트를 공유할 수 있게 해준다.
각 스크립트는 이벤트에만 집중하면 되고 서로를 직접 알 필요가 없기 때문에 구조가 깔끔해진다.
나중에 어떤 스크립트를 추가하거나 제거하더라도 기존 코드는 건드릴 필요가 없다.
5. 옵저버 (Observer)

한 객체의 상태변화가 있을 때
이 객체에 등록된 여러 옵저버들에게 자동으로 알림이 가는 패턴이다.
이벤트 버스 패턴은 이벤트 버스라는 중개자를 통해 객체들이 느슨하게 결합하는 방식이라면
옵저버 패턴은 Subject와 Observer가 직접적으로 연결되는 구조이다.
- Subject: 상태가 변하는 주체 > 옵저버를 등록하고 관리
- Observer: 알림을 받는 객체 > 어떤 반응을 할지 정의
1. 실행 흐름 요약
1.1. 초기 설정
subject.AddObserver(this);
- QuestManager와 ScoreDisplay는 ISubject 인터페이스를 가진 Player 객체에 옵저버로 등록함:
1.2. 점수 상승 (Player.AddScore)
using UnityEngine;
namespace Pattern
{
public class Game : MonoBehaviour
{
void Start ()
{
Player player = new Player ();
player.AddScore(100);
player.AddScore(500);
player.AddScore(1000);
}
}
}
- 점수(score)를 증가시킴
1.3. 옵저버들에게 알림 (NotifyObservers)
using System.Collections.Generic;
using UnityEngine;
namespace Pattern
{
public class Player : ISubject
{
private int score;
public void AddScore(int score)
{
this.score += score;
Debug.Log("현재 점수는 : " + score);
NotifyObservers();
}
public void NotifyObservers()
{
foreach (var observer in Observers)
{
observer.Notify(score);
}
}
}
}
- NotifyObservers() 실행
- 등록된 옵저버들의 Notify(score) 메서드를 호출함
1.4. 각 옵저버가 자신의 방식으로 반응
- QuestManager는 점수가 특정 조건을 넘으면 퀘스트 완료 로그 출력
- ScoreDisplay는 UI 텍스트 갱신
[전체 흐름 정리]
[ Player ] ← 점수 변동
↓
[ NotifyObservers() ] → 등록된 Observer들에게 일괄 알림
↓ ↓
[ QuestManager ] [ ScoreDisplay ]
(100점 퀘스트 클리어) (UI 갱신)
6. 전략 (Strategy)
동작을 캡슐화해서 런타임에 알고리즘을 쉽게 교체할 수 있는 패턴이다.
쉽게 말하면, "행동 방식(전략)을 외부에서 바꿔 끼울 수 있는 구조"다.
using UnityEngine;
public interface IMovement
{
void Move(Transform transform);
}
플레이어 움직임을 예로 들어봤다.
먼저 움직임을 담당할 인터페이스(IMovement)를 만들고, 그 안에 Move()만 정의해뒀다.
using UnityEngine;
public class MoveFly : IMovement
{
public float speed;
public MoveFly(float speed)
{
this.speed = speed;
}
public void Move(Transform transform)
{
transform.Translate(Vector3.up * speed * Time.deltaTime);
}
}
using UnityEngine;
public class MoveRun : IMovement
{
public float speed;
public MoveRun(float speed)
{
this.speed = speed;
}
public void Move(Transform transform)
{
transform.Translate(Vector3.forward * speed * Time.deltaTime);
}
}
using UnityEngine;
public class MoveWalk : IMovement
{
public float speed;
public MoveWalk(float speed)
{
this.speed = speed;
}
public void Move(Transform transform)
{
transform.Translate(Vector3.forward * speed * Time.deltaTime);
}
}
그다음엔 걷기, 달리기, 날기 각각에 맞는 움직임 클래스를 따로 만들어서 IMovement를 구현하게 했다.
이렇게 하면 이동 방식을 하나의 인터페이스 아래에 묶어둘 수 있다.
using UnityEngine;
namespace Pattern
{
public class CharacterMove : MonoBehaviour
{
private IMovement movement;
void Start()
{
movement = new MoveWalk(3f);
}
void Update()
{
Move();
if (Input.GetKeyDown(KeyCode.Alpha1))
{
movement = new MoveWalk(3f);
}
else if (Input.GetKeyDown(KeyCode.Alpha2))
{
movement = new MoveRun(7f);
}
else if (Input.GetKeyDown(KeyCode.Alpha3))
{
movement = new MoveFly(1.5f);
}
}
private void Move()
{
movement.Move(transform);
}
}
}
그리고 실제 움직임을 제어하는 CharacterMove 스크립트에서 처음엔 걷기로 시작하다가
키 입력에 따라 달리기나 날기로 전략을 바꿔준다.
movement = new MoveRun(7f); 이런 식으로 전략 객체만 바꿔주면 Move() 호출 방식은 그대로인데 실제 이동 로직만 달라진다.
[전략 패턴 장점]
- 실행 도중에 동작 방식(전략)을 쉽게 바꿀 수 있음
- CharacterMove는 현재 어떤 전략이 적용됐는지 몰라도 Move()만 호출하면 됨
- 이동 방식이 추가되거나 변경돼도 인터페이스만 지키면 쉽게 확장 가능함
[전략 패턴과 상태 패턴의 차이점]
| 구분 | 전략 패턴 | 상태 패턴 |
| 의도 | 알고리즘(행동 방식)을 바꾸기 위해 | 객체의 상태에 따라 행동을 다르게 하기 위해 |
| 전략 선택 방식 | 보통 호출하는 쪽(클라이언트)이 전략을 명시적으로 선택함 (movement = new MoveWalk() 처럼) | 상태가 내부적으로 바뀌면서 자동으로 상태 객체가 전환됨 |
| 사용 예 | 이동 방식, 정렬 방법, 공격 방식 등 "동작 방법"을 바꾸고 싶을 때 | 상태에 따라 다른 반응이 필요한 경우 (예: Idle, Run, Jump 등) |
| 대표적인 예시 | 걷기/달리기/날기 등 캐릭터 움직임 방식 | 플레이어가 Idle → Jump → Fall 상태로 자동 전환되는 FSM |
상태 패턴이랑 비슷해 보이지만 목적이 다르기 때문에 구분해서 쓰는 게 중요하다.
7. 명령 (Command)
명령 패턴은 요청(동작)을 객체로 캡슐화해서 실행하거나 취소(Undo)할 수 있게 하는 패턴이다.
게임에서 입력을 기록하고 되돌리거나 나중에 한 번에 실행하고 싶을 때 유용하게 쓰인다.
using UnityEngine;
public interface ICommand
{
void Execute(); // Redo
void Cancel(); // Undo
}
ICommand라는 인터페이스를 만들고 여기에 Execute()랑 Cancel() 메서드를 정의해 줬다.
말 그대로 실행과 취소 기능을 명령 단위로 캡슐화한 셈이다.
using Pattern.Command;
using UnityEngine;
public class AttackCommand : ICommand
{
private Player player;
public AttackCommand(Player player)
{
this.player = player;
}
public void Execute()
{
player.Attack();
}
public void Cancel()
{
player.AttackCancel();
}
}
using Pattern.Command;
using UnityEngine;
public class JumpCommand : ICommand
{
private Player player;
public JumpCommand(Player player)
{
this.player = player;
}
public void Execute()
{
player.Jump();
}
public void Cancel()
{
player.JumpCancel();
}
}
using Pattern.Command;
using UnityEngine;
public class SkillCommand : ICommand
{
private Player player;
private string skillName;
public SkillCommand(Player player, string skillName)
{
this.player = player;
this.skillName = skillName;
}
public void Execute()
{
player.UseSkill(skillName);
}
public void Cancel()
{
player.UseSkillCancel(skillName);
}
}
각각의 명령을 Attack, Jump, Skill 클래스로 구현하고
각 클래스는 Player 객체를 받아서 실제로 어떤 명령을 실행할지, 어떻게 취소할지를 정의해 준다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace Pattern.Command
{
public class PlayerController : MonoBehaviour
{
public Player player;
private ICommand attackCommand, jumpCommand, skillCommand;
private Queue<ICommand> commandQueue = new Queue<ICommand>();
private Stack<ICommand> executeCommands = new Stack<ICommand>();
void Awake()
{
attackCommand = new AttackCommand(player);
jumpCommand = new JumpCommand(player);
skillCommand = new SkillCommand(player, "Fireball");
}
void Update()
{
if (Input.GetKeyDown(KeyCode.Q)) // 공격 기능
{
attackCommand.Execute();
executeCommands.Push(attackCommand);
}
else if (Input.GetKeyDown(KeyCode.W)) // 점프 기능
{
jumpCommand.Execute();
executeCommands.Push(jumpCommand);
}
else if (Input.GetKeyDown(KeyCode.E)) // 스킬 기능
{
skillCommand.Execute();
executeCommands.Push(skillCommand);
}
if (Input.GetKeyDown(KeyCode.Alpha1)) // 공격 기능
{
commandQueue.Enqueue(attackCommand);
}
else if (Input.GetKeyDown(KeyCode.Alpha2)) // 점프 기능
{
commandQueue.Enqueue(jumpCommand);
}
else if (Input.GetKeyDown(KeyCode.Alpha3)) // 스킬 기능
{
commandQueue.Enqueue(skillCommand);
}
if (Input.GetKeyDown(KeyCode.Space))
{
Debug.Log("턴 종료 및 명령 실행");
while (commandQueue.Count > 0)
{
ICommand command = commandQueue.Dequeue();
command.Execute();
executeCommands.Push(command);
}
}
if (Input.GetKeyDown(KeyCode.Z)) // 취소 기능
{
if (executeCommands.Count > 0)
{
ICommand lastCommand = executeCommands.Pop(); // 가장 최근에 실행한 명령
Debug.Log($"명령 취소 : {lastCommand.GetType().Name}");
lastCommand.Cancel(); // Undo
}
else
{
Debug.Log("되돌릴 명령이 없습니다.");
}
}
}
}
}
여기서 명령을 사용하는 흐름은 두 가지이다.
즉시 실행
- Q, W, E 키를 누르면 즉시 실행 (Execute())
- 실행한 명령은 Stack에 저장 → 나중에 Z 키로 하나씩 Cancel() 가능
커맨드 큐잉
- 1, 2, 3 키를 누르면 명령을 Queue에 쌓아둠
- 스페이스바를 누르면 한꺼번에 실행 (턴제 게임 같은 느낌)
'Unity > 멋쟁이사자처럼 부트캠프' 카테고리의 다른 글
| [멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(58일차) - 데이터 파싱 (2), API 활용 (9) | 2025.08.10 |
|---|---|
| [멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(57일차) - 게임 디자인 패턴 (2), ScriptableObject, 데이터 파싱 (6) | 2025.08.07 |
| [멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(55일차) - C# 중급 (3), LINQ (3) | 2025.08.05 |
| [멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(54일차) - C# 중급 (2) / Delegate, Func, Action 등 Event 실습 (3) | 2025.08.04 |
| [멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(53일차) - 3D 편집도구 실습 및 C# 중급 (1) (5) | 2025.08.01 |