콘텐츠로 이동

게임 개발에서 자주 쓰는 디자인 패턴

GoF 디자인 패턴은 게임 개발에서도 핵심 문제들을 해결하는 데 자주 등장합니다. 그러나 게임의 맥락에서는 교과서적 구현보다 성능과 사용 편의성을 함께 고려한 실용적 변형이 더 자주 쓰입니다. 여기서는 게임에서 가장 활용도가 높은 네 가지 패턴을 실무 관점으로 정리합니다.


입력 처리나 게임 액션을 객체로 캡슐화합니다. 명령 히스토리를 유지하면 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();
}
};

캐릭터 상태 전환 로직을 상태 클래스로 분리합니다. 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*) {}
};

게임 오브젝트 간 직접 참조 없이 이벤트를 전달합니다. 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");

전역 서비스에 대한 접근을 중앙화합니다. 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 Locator
class 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로 검증

비용이 큰 연산의 결과를 캐싱하고, 데이터가 변경됐을 때만 재계산합니다.

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 시야 계산: 플레이어가 이동했을 때만 재계산

읽기와 쓰기에 각각 다른 버퍼를 사용해 불완전한 중간 상태가 노출되지 않도록 합니다.

// 게임 상태 더블 버퍼링
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(); }

공간을 분할해 근거리 오브젝트 검색을 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: 레벨 지오메트리 분할, 렌더링 순서 결정 (레거시)

문제패턴이유
Undo/Redo, 리플레이Command명령을 객체로 기록
캐릭터/AI 상태 전환State상태별 로직 분리
오브젝트 간 결합 제거Observer직접 참조 없이 이벤트 전달
대량 오브젝트 메모리Flyweight공유 데이터 풀링
오브젝트 생성 추상화Factory/Pool생성 비용 및 타입 결합 제거
전역 서비스 테스트 가능하게Service LocatorNull Object로 Mock 교체
비용 큰 연산 캐싱Dirty Flag변경 시에만 재계산
멀티스레드 안전 렌더링Double Buffer읽기·쓰기 버퍼 분리
근거리 오브젝트 검색 최적화Spatial PartitionO(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)에서 대폭 최적화한다.