게임 개발에서 자주 쓰는 디자인 패턴
GoF 디자인 패턴은 게임 개발에서도 핵심 문제들을 해결하는 데 자주 등장합니다. 그러나 게임의 맥락에서는 교과서적 구현보다 성능과 사용 편의성을 함께 고려한 실용적 변형이 더 자주 쓰입니다. 여기서는 게임에서 가장 활용도가 높은 네 가지 패턴을 실무 관점으로 정리합니다.
1. Command 패턴 — Undo/Redo 시스템
섹션 제목: “1. Command 패턴 — Undo/Redo 시스템”입력 처리나 게임 액션을 객체로 캡슐화합니다. 명령 히스토리를 유지하면 Undo/Redo가 자연스럽게 구현됩니다.
// ICommand 인터페이스class ICommand{public: virtual ~ICommand() = default; virtual void Execute() = 0; virtual void Undo() = 0;};
// 구체적인 명령class MoveCommand : public ICommand{ GameObject& _target; glm::vec3 _delta; glm::vec3 _prevPos;
public: MoveCommand(GameObject& target, glm::vec3 delta) : _target(target), _delta(delta) {}
void Execute() override { _prevPos = _target.Position; _target.Position += _delta; }
void Undo() override { _target.Position = _prevPos; }};
// 커맨드 관리자class CommandHistory{ std::stack<std::unique_ptr<ICommand>> _history; std::stack<std::unique_ptr<ICommand>> _redoStack;
public: void Execute(std::unique_ptr<ICommand> cmd) { cmd->Execute(); _history.push(std::move(cmd)); // 새 명령 실행 시 redo 스택 초기화 while (!_redoStack.empty()) _redoStack.pop(); }
void Undo() { if (_history.empty()) return; _history.top()->Undo(); _redoStack.push(std::move(_history.top())); _history.pop(); }
void Redo() { if (_redoStack.empty()) return; _redoStack.top()->Execute(); _history.push(std::move(_redoStack.top())); _redoStack.pop(); }};2. State 패턴 — 캐릭터 FSM
섹션 제목: “2. State 패턴 — 캐릭터 FSM”캐릭터 상태 전환 로직을 상태 클래스로 분리합니다. switch/if-else 중첩을 제거하고 상태별 진입/탈출 로직을 명확히 합니다.
class Character;
class ICharacterState{public: virtual ~ICharacterState() = default; virtual void Enter(Character& c) {} virtual void Update(Character& c, float dt) = 0; virtual void Exit(Character& c) {}};
class IdleState : public ICharacterState{public: void Update(Character& c, float dt) override { if (c.IsMoving()) c.SetState(std::make_unique<MoveState>()); if (c.IsAttackInput()) c.SetState(std::make_unique<AttackState>()); }};
class AttackState : public ICharacterState{ float _timer = 0.f; static constexpr float AttackDuration = 0.5f;
public: void Enter(Character& c) override { c.PlayAnimation("Attack"); }
void Update(Character& c, float dt) override { _timer += dt; if (_timer >= AttackDuration) c.SetState(std::make_unique<IdleState>()); }
void Exit(Character& c) override { c.StopAnimation("Attack"); }};
class Character{ std::unique_ptr<ICharacterState> _state;
public: Character() : _state(std::make_unique<IdleState>()) {}
void SetState(std::unique_ptr<ICharacterState> newState) { if (_state) _state->Exit(*this); _state = std::move(newState); _state->Enter(*this); }
void Update(float dt) { if (_state) _state->Update(*this, dt); }
bool IsMoving() const { return /* 이동 입력 감지 */ false; } bool IsAttackInput() const { return /* 공격 입력 감지 */ false; } void PlayAnimation(const char*) {} void StopAnimation(const char*) {}};3. Observer 패턴 — 이벤트 버스
섹션 제목: “3. Observer 패턴 — 이벤트 버스”게임 오브젝트 간 직접 참조 없이 이벤트를 전달합니다. Unity의 UnityEvent, UE5의 Delegate/Multicast가 이 패턴의 엔진 구현체입니다.
#include <functional>#include <unordered_map>#include <vector>#include <typeindex>#include <any>
class EventBus{ using Handler = std::function<void(const std::any&)>; std::unordered_map<std::type_index, std::vector<Handler>> _handlers;
public: template<typename EventT> void Subscribe(std::function<void(const EventT&)> handler) { _handlers[typeid(EventT)].push_back( [handler](const std::any& e) { handler(std::any_cast<const EventT&>(e)); }); }
template<typename EventT> void Publish(const EventT& event) { auto it = _handlers.find(typeid(EventT)); if (it == _handlers.end()) return; for (auto& h : it->second) h(event); }};
// 이벤트 타입struct PlayerDiedEvent { int playerId; };struct EnemyKilledEvent { int enemyId; int score; };
// 사용EventBus bus;
bus.Subscribe<EnemyKilledEvent>([](const EnemyKilledEvent& e){ std::cout << "Enemy " << e.enemyId << " killed! +" << e.score << " pts\n";});
bus.Publish(EnemyKilledEvent{42, 100});4. Flyweight 패턴 — 메모리 최적화
섹션 제목: “4. Flyweight 패턴 — 메모리 최적화”수천 개의 동일한 데이터(메시, 텍스처, 파티클 설정)를 공유해 메모리를 절약합니다.
// 불변 공유 데이터 (Flyweight)struct BulletData{ std::string meshPath; float speed; float damage; float radius;};
// 가변 인스턴스 데이터struct BulletInstance{ const BulletData* data; // 공유 참조 glm::vec3 position; glm::vec3 velocity; float lifetime;};
class BulletFactory{ std::unordered_map<std::string, BulletData> _pool;
public: const BulletData* GetData(const std::string& type) { auto [it, inserted] = _pool.try_emplace(type); if (inserted) { // 최초 1회 로드 it->second = LoadBulletData(type); } return &it->second; }
private: BulletData LoadBulletData(const std::string& type) { // 파일/설정에서 로드 return { type + ".mesh", 50.f, 10.f, 0.1f }; }};
// 1000개 총알: BulletData는 1개, BulletInstance는 1000개BulletFactory factory;std::vector<BulletInstance> bullets(1000);for (auto& b : bullets) b.data = factory.GetData("pistol_bullet");5. Service Locator 패턴
섹션 제목: “5. Service Locator 패턴”전역 서비스에 대한 접근을 중앙화합니다. Singleton보다 유연하며 테스트 시 Mock 서비스로 교체할 수 있습니다.
// 서비스 인터페이스class IAudioService{public: virtual ~IAudioService() = default; virtual void PlaySound(const std::string& id) = 0; virtual void StopAll() = 0;};
// Null Object: 서비스 미등록 시 사용 (크래시 방지)class NullAudioService : public IAudioService{public: void PlaySound(const std::string&) override {} // 아무것도 안 함 void StopAll() override {}};
// Service Locatorclass ServiceLocator{ static inline std::unique_ptr<IAudioService> _audio = std::make_unique<NullAudioService>(); // 기본: Null 서비스
public: static IAudioService& Audio() { return *_audio; }
static void ProvideAudio(std::unique_ptr<IAudioService> service) { _audio = service ? std::move(service) : std::make_unique<NullAudioService>(); }};
// 실제 서비스 구현class FMODAudioService : public IAudioService{public: void PlaySound(const std::string& id) override { std::cout << "FMOD PlaySound: " << id << "\n"; } void StopAll() override { std::cout << "FMOD StopAll\n"; }};
// 사용ServiceLocator::ProvideAudio(std::make_unique<FMODAudioService>());ServiceLocator::Audio().PlaySound("explosion");
// 테스트 시: Mock 서비스 등록class MockAudio : public IAudioService{public: std::vector<std::string> playedSounds; void PlaySound(const std::string& id) override { playedSounds.push_back(id); } void StopAll() override {}};
auto mock = std::make_unique<MockAudio>();MockAudio* mockPtr = mock.get();ServiceLocator::ProvideAudio(std::move(mock));// 테스트 완료 후 playedSounds로 검증6. Dirty Flag 패턴
섹션 제목: “6. Dirty Flag 패턴”비용이 큰 연산의 결과를 캐싱하고, 데이터가 변경됐을 때만 재계산합니다.
class Transform{public: void SetPosition(glm::vec3 pos) { _localPosition = pos; _isDirty = true; // 재계산 필요 표시 }
void SetRotation(glm::quat rot) { _localRotation = rot; _isDirty = true; }
// 월드 행렬이 필요할 때만 계산 const glm::mat4& GetWorldMatrix() { if (_isDirty) { RecalculateWorldMatrix(); _isDirty = false; } return _worldMatrix; }
private: void RecalculateWorldMatrix() { // TRS 행렬 계산 (비용이 큰 연산) glm::mat4 T = glm::translate(glm::mat4(1.0f), _localPosition); glm::mat4 R = glm::mat4_cast(_localRotation); glm::mat4 S = glm::scale(glm::mat4(1.0f), _localScale); _worldMatrix = T * R * S;
if (_parent) _worldMatrix = _parent->GetWorldMatrix() * _worldMatrix; }
glm::vec3 _localPosition{0.f}; glm::quat _localRotation{1.f, 0.f, 0.f, 0.f}; glm::vec3 _localScale{1.f}; glm::mat4 _worldMatrix{1.0f}; bool _isDirty = true; Transform* _parent = nullptr;};
// 활용:// - 씬 그래프: 부모가 이동하면 자식의 worldMatrix dirty 표시// - 셰이더 파라미터: 값이 변경됐을 때만 GPU에 업로드// - AI 시야 계산: 플레이어가 이동했을 때만 재계산7. Double Buffer 패턴
섹션 제목: “7. Double Buffer 패턴”읽기와 쓰기에 각각 다른 버퍼를 사용해 불완전한 중간 상태가 노출되지 않도록 합니다.
// 게임 상태 더블 버퍼링template<typename T>class DoubleBuffer{ T _buffers[2]; int _currentRead = 0; int _currentWrite = 1;
public: T& GetCurrent() { return _buffers[_currentRead]; } // 렌더 스레드가 읽음 T& GetNext() { return _buffers[_currentWrite]; } // 로직 스레드가 씀
void Swap() { std::swap(_currentRead, _currentWrite); }};
// 파티클 시스템에서 더블 버퍼링struct ParticleState{ std::vector<glm::vec3> positions; std::vector<glm::vec3> velocities; std::vector<float> lifetimes;};
DoubleBuffer<ParticleState> particleBuffer;
// 로직 스레드: 다음 프레임 상태 계산void UpdateParticles(float dt){ auto& next = particleBuffer.GetNext(); auto& curr = particleBuffer.GetCurrent();
for (size_t i = 0; i < curr.positions.size(); ++i) { next.positions[i] = curr.positions[i] + curr.velocities[i] * dt; next.lifetimes[i] = curr.lifetimes[i] - dt; }}
// 렌더 스레드: 현재 버퍼를 안전하게 읽음void RenderParticles(){ auto& curr = particleBuffer.GetCurrent(); for (size_t i = 0; i < curr.positions.size(); ++i) DrawParticle(curr.positions[i]);}
// 프레임 경계에서 스왑 (두 스레드가 동기화된 시점에)void OnFrameEnd() { particleBuffer.Swap(); }8. Spatial Partition 패턴
섹션 제목: “8. Spatial Partition 패턴”공간을 분할해 근거리 오브젝트 검색을 O(n)에서 O(log n) 또는 O(1)로 최적화합니다.
// 단순 격자 기반 공간 분할 (Grid-based Spatial Partitioning)class SpatialGrid{ static constexpr float CELL_SIZE = 10.0f;
struct Cell { std::vector<int> entityIds; // 셀 안의 엔티티 ID 목록 };
std::unordered_map<uint64_t, Cell> _grid;
uint64_t CellKey(int gx, int gz) const { return (static_cast<uint64_t>(gx) << 32) | static_cast<uint32_t>(gz); }
std::pair<int,int> WorldToGrid(glm::vec3 pos) const { return { static_cast<int>(std::floor(pos.x / CELL_SIZE)), static_cast<int>(std::floor(pos.z / CELL_SIZE)) }; }
public: void Insert(int entityId, glm::vec3 position) { auto [gx, gz] = WorldToGrid(position); _grid[CellKey(gx, gz)].entityIds.push_back(entityId); }
// 특정 위치 주변 radius 내 엔티티만 검색 std::vector<int> Query(glm::vec3 center, float radius) { std::vector<int> result; int cellRadius = static_cast<int>(std::ceil(radius / CELL_SIZE)); auto [cx, cz] = WorldToGrid(center);
for (int dx = -cellRadius; dx <= cellRadius; ++dx) for (int dz = -cellRadius; dz <= cellRadius; ++dz) { auto it = _grid.find(CellKey(cx+dx, cz+dz)); if (it != _grid.end()) for (int id : it->second.entityIds) result.push_back(id); } return result; }};
// 활용: 10,000명 플레이어 중 반경 50m 내 적 검색// Without: 모든 10,000명 순회 O(n)// With Grid (CELL_SIZE=10): 약 25개 셀만 확인 O(1)에 근접
// 다른 공간 분할 구조:// Quad-Tree: 2D 공간 분할, 비균등 분포에 효율적// Oct-Tree: 3D 공간 분할, 오픈 월드 충돌 감지// KD-Tree: 최근접 이웃 탐색에 최적, 경로 탐색 AI// BSP Tree: 레벨 지오메트리 분할, 렌더링 순서 결정 (레거시)9. 패턴 선택 가이드
섹션 제목: “9. 패턴 선택 가이드”| 문제 | 패턴 | 이유 |
|---|---|---|
| Undo/Redo, 리플레이 | Command | 명령을 객체로 기록 |
| 캐릭터/AI 상태 전환 | State | 상태별 로직 분리 |
| 오브젝트 간 결합 제거 | Observer | 직접 참조 없이 이벤트 전달 |
| 대량 오브젝트 메모리 | Flyweight | 공유 데이터 풀링 |
| 오브젝트 생성 추상화 | Factory/Pool | 생성 비용 및 타입 결합 제거 |
| 전역 서비스 테스트 가능하게 | Service Locator | Null Object로 Mock 교체 |
| 비용 큰 연산 캐싱 | Dirty Flag | 변경 시에만 재계산 |
| 멀티스레드 안전 렌더링 | Double Buffer | 읽기·쓰기 버퍼 분리 |
| 근거리 오브젝트 검색 최적화 | Spatial Partition | O(n)→O(1) 공간 쿼리 |
- Command 패턴은 입력 처리를 객체화해 Undo/Redo와 리플레이 시스템을 자연스럽게 구현한다.
- State 패턴은 캐릭터 FSM에서 if/switch 중첩을 제거하고, Enter/Exit 훅으로 상태 전환 부수 효과를 캡슐화한다.
- Observer/이벤트 버스는 오브젝트 간 직접 참조를 없애 결합도를 낮추지만, 구독 해제를 잊으면 메모리 누수가 발생한다.
- Flyweight는 수천 개의 동일한 오브젝트에서 공유 가능한 불변 데이터를 분리해 메모리를 절약한다.
- Service Locator는 Singleton보다 유연하게 전역 서비스를 관리하고 테스트 시 Mock 교체가 가능하다.
- Dirty Flag는 Transform처럼 빈번히 읽히지만 변경은 가끔인 데이터에 캐싱 비용을 최소화한다.
- Double Buffer는 렌더링과 업데이트를 분리해 멀티스레드 게임 루프에서 데이터 충돌을 방지한다.
- Spatial Partition은 충돌 감지, 시야 판별, 근처 적 탐색 등 공간 쿼리를 O(n)에서 대폭 최적화한다.