디자인 패턴 핵심 정리

디자인 패턴(Design Pattern)은 소프트웨어를 개발하면서 반복적으로 등장하는 설계 문제를 해결하기 위해 정리된 설계 방법이다.

특정 언어나 프레임워크의 기능이 아니다. C++, Java, C# 같은 객체지향 언어뿐만 아니라 상황에 따라 다양한 프로그래밍 환경에서 적용할 수 있다.

디자인 패턴이 필요한 이유는 코드가 커질수록 단순히 기능을 구현하는 것만으로는 구조를 유지하기 어려워지기 때문이다.

처음에는 다음 정도로 충분할 수 있다.

void CreateEnemy()
{
    // 적 생성
}

하지만 게임이 커지면서 적의 종류가 늘어나고 생성 방식도 다양해지면 코드가 점점 복잡해진다.

Enemy 생성 -> 일반 몬스터 / 보스 / 원거리 몬스터 / 근접 몬스터 / 이벤트 몬스터 / 네트워크에서 생성 / 저장 데이터에서 복원

이런 문제를 반복해서 해결하다 보면 비슷한 구조가 계속 등장한다.

디자인 패턴은 이런 문제에 대한 설계 경험을 이름 붙여 정리한 것이다.

대표적인 디자인 패턴은 GoF(Gang of Four)의 Design Patterns: Elements of Reusable Object-Oriented Software에서 정리한 23개의 패턴을 기준으로 설명하는 경우가 많다.


디자인 패턴을 보는 관점

디자인 패턴을 외우는 것보다 먼저 알아야 할 것은 어떤 문제를 해결하기 위한 구조인가다.

예를 들어 객체를 하나만 만들어야 하는 문제가 있다면 Singleton을 생각할 수 있고, 객체 생성 과정을 분리해야 한다면 Factory 계열 패턴을 고려할 수 있다.

객체 간의 관계를 관리해야 한다면 Observer나 Mediator 같은 패턴이 등장한다.

객체의 행동 자체를 상황에 따라 변경해야 한다면 Strategy나 State를 사용할 수 있다.

대략적으로 다음과 같이 분류할 수 있다.

생성 패턴 -> 객체를 어떻게 생성할 것인가
구조 패턴 -> 객체와 클래스를 어떻게 구성할 것인가
행동 패턴 -> 객체들이 어떻게 협력하고 책임을 나눌 것인가

이 세 가지 관점으로 보면 패턴을 외우는 부담이 많이 줄어든다.


생성 패턴

생성 패턴은 객체 생성 과정에 대한 문제를 다룬다.

객체를 직접 new 하는 것 자체가 문제가 아니라, 객체 생성 방식이 복잡해지거나 생성 코드가 특정 구현에 강하게 결합되는 상황을 해결하기 위해 사용한다.


Singleton

Singleton은 특정 클래스의 인스턴스를 하나만 존재하도록 관리하는 패턴이다.

class GameManager
{
public:
    static GameManager& GetInstance()
    {
        static GameManager instance;
        return instance;
    }

private:
    GameManager() = default;
};

사용하는 쪽에서는 다음과 같이 접근할 수 있다.

GameManager::GetInstance();

대표적으로 전역적으로 하나만 존재해야 하는 관리 객체를 표현할 때 사용된다.

GameManager / ConfigManager / Logger / ResourceManager

다만 Singleton은 편리하다는 이유만으로 사용하면 문제가 생기기 쉽다.

여러 시스템에서 Singleton을 직접 참조하면 객체 사이의 의존성이 코드 곳곳에 퍼진다.

A -> Singleton / B -> Singleton / C -> Singleton / D -> Singleton

이 구조는 테스트하기 어렵고 시스템 간 결합도도 높아진다.

따라서 Singleton은 “전역 객체를 쉽게 접근하는 방법”으로 생각하기보다 생명주기와 인스턴스 개수를 하나로 제한해야 하는 경우에 제한적으로 사용하는 편이 좋다.


Factory Method

Factory Method는 객체 생성 코드를 별도의 메서드나 객체로 분리하는 패턴이다.

직접 구현 클래스를 생성하지 않고 생성 책임을 다른 곳으로 넘긴다.

class Enemy
{
public:
    virtual ~Enemy() = default;
};

class Goblin : public Enemy
{
};

class Orc : public Enemy
{
};

class EnemyFactory
{
public:
    static std::unique_ptr<Enemy> Create(int type)
    {
        switch (type)
        {
        case 0:
            return std::make_unique<Goblin>();

        case 1:
            return std::make_unique<Orc>();
        }

        return nullptr;
    }
};

사용하는 코드는 구체적인 클래스 생성 방식보다 다음과 같은 생성 요청에 집중할 수 있다.

auto enemy = EnemyFactory::Create(type);

게임에서 캐릭터, 몬스터, 아이템, UI 객체 등을 생성할 때 자주 볼 수 있는 구조다.


Abstract Factory

Abstract Factory는 서로 관련된 객체들을 하나의 계열로 생성하기 위한 패턴이다.

예를 들어 UI 시스템에서 운영체제별 버튼과 윈도우를 생성한다고 생각할 수 있다.

Windows UI Factory -> Windows Button / Windows Window
Mac UI Factory -> Mac Button / Mac Window

사용하는 쪽에서는 구체적인 구현 클래스보다 Factory 인터페이스에 의존한다.

Application -> IUIFactory -> WindowsUIFactory / MacUIFactory

관련 객체들이 서로 호환되는 조합을 유지해야 할 때 유용하다.


Builder

Builder는 복잡한 객체를 여러 단계에 걸쳐 구성할 때 사용한다.

생성자 매개변수가 너무 많아지는 상황을 생각하면 이해하기 쉽다.

Character character(
    100,
    50,
    20,
    true,
    false,
    "Knight",
    ...
);

무엇이 어떤 값인지 읽기 어렵다.

Builder를 사용하면 다음처럼 객체 생성 과정을 단계별로 표현할 수 있다.

Character character = CharacterBuilder()
    .SetName("Knight")
    .SetHealth(100)
    .SetMana(50)
    .SetLevel(20)
    .Build();

설정값이 많고 선택적인 옵션이 많은 객체에서 유용하다.


Prototype

Prototype은 기존 객체를 복제해서 새로운 객체를 생성하는 방식이다.

auto clone = original->Clone();

객체를 새로 구성하는 비용이 크거나 객체의 초기 상태를 복사해서 새로운 객체를 만들고 싶을 때 사용할 수 있다.

게임에서는 몬스터의 기본 설정이나 이펙트 설정 같은 데이터를 복제하는 구조와 잘 맞을 수 있다.

다만 C++에서는 복사 생성자, 복사 대입, 스마트 포인터 등을 활용할 수 있기 때문에 상황에 따라 별도의 Prototype 패턴이 필요하지 않을 수도 있다.


구조 패턴

구조 패턴은 클래스와 객체를 어떻게 조합할 것인지에 집중한다.

객체 자체의 생성보다 기존 객체를 어떻게 연결하고 감쌀 것인가가 문제의 중심이다.


Adapter

Adapter는 서로 다른 인터페이스를 가진 객체를 호환시켜주는 패턴이다.

예를 들어 게임 코드가 다음 인터페이스를 기대한다고 하자.

class IAudioSystem
{
public:
    virtual void PlaySound(int id) = 0;
};

그런데 외부 라이브러리는 다음과 같은 형태라면 직접 사용할 수 없다.

class ExternalAudio
{
public:
    void Play(int soundId);
};

Adapter를 만들 수 있다.

class AudioAdapter : public IAudioSystem
{
public:
    AudioAdapter(ExternalAudio& audio)
        : audio(audio)
    {
    }

    void PlaySound(int id) override
    {
        audio.Play(id);
    }

private:
    ExternalAudio& audio;
};

게임 코드는 외부 라이브러리의 인터페이스를 알 필요가 없다.

Adapter는 레거시 코드, 외부 라이브러리, 플랫폼 API를 기존 시스템에 연결할 때 특히 유용하다.


Decorator

Decorator는 기존 객체를 수정하지 않고 기능을 동적으로 추가하는 패턴이다.

예를 들어 캐릭터의 공격 기능에 여러 효과를 추가한다고 생각할 수 있다.

Attack -> Critical -> Fire Damage -> Poison -> Lifesteal

각 기능을 조합할 수 있도록 구성한다.

Base Attack -> Critical Decorator -> Fire Decorator -> Lifesteal Decorator

상속으로 모든 조합을 만들면 다음과 같이 클래스가 폭발할 수 있다.

FireAttack / CriticalAttack / FireCriticalAttack / FireCriticalLifestealAttack / ...

Decorator는 이런 기능 조합 문제를 객체 구성으로 해결할 수 있다.


Facade

Facade는 복잡한 여러 시스템을 단순한 인터페이스 뒤에 숨기는 패턴이다.

예를 들어 게임을 시작하려면 다음 시스템들을 순서대로 초기화해야 할 수 있다.

ResourceManager / AudioManager / NetworkManager / SceneManager / UIManager

외부에서 각각 호출하는 대신 다음처럼 하나로 묶을 수 있다.

GameSystem::Initialize();

내부에서는 다음과 같은 초기화 흐름을 처리한다.

GameSystem -> ResourceManager -> AudioManager -> NetworkManager -> SceneManager -> UIManager

Facade는 내부 시스템의 복잡성을 사용하는 쪽에서 알 필요 없도록 만들어준다.


Composite

Composite는 개별 객체와 객체들의 집합을 동일한 방식으로 다루는 패턴이다.

대표적인 예가 UI나 Scene Graph다.

트리 구조를 표현하면 다음과 같은 관계가 된다.

Scene -> Player / Enemy / UI -> Button / Text

단일 객체도 Update()를 가지고 있고 그룹도 Update()를 가지고 있다면 상위 코드에서는 둘을 동일하게 처리할 수 있다.

class Node
{
public:
    virtual void Update() = 0;
};

그리고 Leaf와 Composite 모두 Node를 구현한다.

Node -> Leaf / Composite

트리 구조를 다룰 때 특히 자연스럽게 적용된다.


Proxy

Proxy는 실제 객체 대신 앞에서 접근을 제어하는 객체를 두는 패턴이다.

Client -> Proxy -> Real Object

Proxy에서 다음과 같은 작업을 처리할 수 있다.

접근 권한 검사 / 캐싱 / 로깅 / 지연 로딩 / 원격 호출

실제 객체에 접근하기 전에 추가적인 제어가 필요할 때 사용한다.


행동 패턴

행동 패턴은 객체 간의 책임과 협력 방식을 다룬다.

게임 개발에서 특히 많이 접하게 되는 패턴들이 여기에 포함된다.


Observer

Observer는 한 객체의 상태가 변경되었을 때 여러 객체에 변경 사실을 전달하는 패턴이다.

Subject -> Observer A / Observer B / Observer C

예를 들어 플레이어 체력이 변경되었을 때 다음 시스템에 이벤트를 전달할 수 있다.

Player -> UI / Quest System / Achievement System / Sound System

C#의 이벤트, C++의 delegate/event 시스템, 다양한 메시지 시스템에서도 비슷한 구조를 볼 수 있다.

다만 Observer가 많아지면 이벤트가 어디에서 발생하고 어디에서 처리되는지 추적하기 어려워질 수 있다.


Strategy

Strategy는 동일한 목적을 수행하는 알고리즘을 객체로 분리하고 필요에 따라 교체하는 패턴이다.

예를 들어 NPC의 이동 방식을 다음과 같이 변경한다고 하자.

A* Pathfinding / Random Movement / Chase Player / Flee Player

이를 별도의 전략으로 분리할 수 있다.

class IMovementStrategy
{
public:
    virtual void Move() = 0;
};

각 전략을 별도로 구현한다.

Character -> IMovementStrategy -> AStarMovement / RandomMovement / ChaseMovement

조건문으로 모든 알고리즘을 관리하는 것보다 변경과 확장이 쉬워질 수 있다.


State

State는 객체의 상태에 따라 행동을 분리하는 패턴이다.

게임에서 매우 자주 등장한다.

Player -> Idle -> Move -> Attack -> Hit -> Dead

상태가 많아지면 다음과 같은 코드가 등장하기 쉽다.

if (state == Idle)
{
}
else if (state == Move)
{
}
else if (state == Attack)
{
}
else if (state == Dead)
{
}

State 패턴을 적용하면 상태별 행동을 객체 또는 별도의 구조로 분리할 수 있다.

Player -> IdleState / MoveState / AttackState / DeadState

특히 FSM(Finite State Machine)과 함께 게임 AI, 캐릭터 상태 관리 등에 자주 사용된다.


Command

Command는 요청 자체를 객체로 만드는 패턴이다.

일반적으로 다음처럼 바로 실행하는 작업을

player.Move(x, y);

MoveCommand라는 객체로 표현할 수 있다.

그러면 명령을 저장하거나 실행을 지연하거나 취소할 수 있다.

Input -> Command -> Queue -> Execute

게임에서는 입력 처리, Undo/Redo, 리플레이, 네트워크 명령 처리 등에 활용할 수 있다.

예를 들어 다음과 같이 플레이어의 행동을 명령 객체로 표현할 수 있다.

MoveCommand / AttackCommand / UseItemCommand

Template Method

Template Method는 전체 알고리즘의 구조를 상위 클래스에서 정의하고 일부 세부 구현을 하위 클래스에서 변경하는 패턴이다.

void Process()
{
    Load();
    Execute();
    Save();
}

전체 순서는 동일하지만 세부 구현은 클래스마다 다를 수 있다.

Process -> Load -> Execute -> Save

다만 상속 기반 구조이기 때문에 현대적인 코드에서는 Strategy나 Composition으로 더 적절하게 해결할 수 있는 경우도 많다.


Chain of Responsibility

Chain of Responsibility는 요청을 여러 처리 객체가 순차적으로 전달받도록 구성하는 패턴이다.

Request -> Handler A -> Handler B -> Handler C -> Handler D

각 Handler가 요청을 처리할 수 있으면 처리하고, 아니면 다음 Handler에게 넘긴다.

예를 들어 입력 처리에서 다음과 같은 흐름을 만들 수 있다.

Input -> UI -> Inventory -> Character -> Game

HTTP 미들웨어 구조에서도 비슷한 형태를 볼 수 있다.


Mediator

Mediator는 객체들이 서로 직접 통신하지 않고 중재자를 통해 통신하도록 만드는 패턴이다.

객체가 서로 직접 참조하면 다음과 같이 연결 관계가 복잡해질 수 있다.

A <-> B / A <-> C / B <-> C / B <-> D / C <-> D

Mediator를 두면 다음처럼 통신을 중앙에서 관리할 수 있다.

A / B / C / D -> Mediator

UI 시스템이나 여러 객체가 복잡하게 상호작용하는 시스템에서 사용할 수 있다.

다만 Mediator가 모든 로직을 가지고 거대한 God Object가 되지 않도록 주의해야 한다.


자주 같이 사용되는 패턴

실제 프로젝트에서는 패턴 하나만 독립적으로 사용하는 경우보다 여러 패턴을 조합하는 경우가 많다.

예를 들어 게임의 캐릭터 생성 시스템을 생각할 수 있다.

캐릭터 생성은 Factory로 분리할 수 있다.

CharacterFactory -> Warrior / Mage / Archer

캐릭터의 행동은 Strategy로 분리할 수 있다.

Character -> AttackStrategy / MovementStrategy

캐릭터 상태는 State로 관리할 수 있다.

Character -> IdleState / AttackState / DeadState

상태가 변경되면 Observer를 통해 UI나 다른 시스템에 알릴 수 있다.

Character -> Observer -> UI / Quest / Achievement

이처럼 패턴은 서로 경쟁하는 개념이 아니라 서로 다른 문제를 해결하는 도구다.


디자인 패턴과 SOLID의 관계

둘을 같은 것으로 생각하면 안 된다.

SOLID는 객체지향 설계의 일반적인 원칙이고 디자인 패턴은 특정 문제에 대한 반복적인 설계 해결책이다.

SOLID -> 설계 원칙
Design Pattern -> 반복되는 설계 문제에 대한 구조

Strategy 패턴은 OCP와 DIP를 적용하는 과정에서 자연스럽게 등장할 수 있고, Factory도 객체 생성 책임을 분리하면서 의존성을 낮추는 데 사용할 수 있다.

Clean Code 역시 이들과 연결된다.

Clean Code -> 읽고 수정하기 쉬운 코드
SOLID -> 변경에 강한 객체지향 설계 원칙
Design Pattern -> 반복되는 설계 문제를 해결하는 구조

세 가지를 별개의 지식으로 외우기보다는 서로 연결해서 보는 편이 좋다.


디자인 패턴을 무조건 사용하면 안 되는 이유

디자인 패턴에는 명확한 단점도 있다.

코드가 단순한데 패턴을 적용하면 구조가 복잡해질 수 있다.

예를 들어 단순히 객체 하나를 생성하면 되는 상황에서 Factory, Abstract Factory, Builder를 전부 적용한다면 코드의 양만 늘어난다.

문제 없음 -> 패턴 적용 -> 클래스 증가 -> 인터페이스 증가 -> 의존성 증가 -> 이해하기 어려움

이런 상황은 디자인 패턴의 목적과 반대다.

패턴은 코드를 복잡하게 만드는 장치가 아니다.

현재 존재하는 복잡성을 관리하기 위한 도구다.

따라서 먼저 문제를 보고 패턴을 선택해야 한다.


디자인 패턴을 공부하는 방법

패턴의 클래스 구조를 외우는 것보다 다음 순서로 보는 것이 좋다.

문제 -> 기존 구조의 한계 -> 해결해야 하는 요구사항 -> 설계 아이디어 -> 패턴 적용 -> 장점 -> 단점 -> 적용하지 않는 경우

예를 들어 Strategy를 공부한다면 다음과 같이 이해할 수 있다.

조건문이 너무 많다 -> 알고리즘이 계속 추가된다 -> 알고리즘을 독립적으로 교체하고 싶다 -> 알고리즘을 객체로 분리한다 -> Strategy

그러면 패턴 이름을 잊어버려도 비슷한 문제가 발생했을 때 다시 설계를 만들어낼 수 있다.


실무에서 자주 접하는 패턴

모든 GoF 패턴을 같은 빈도로 사용하는 것은 아니다.

실제 개발에서는 언어나 프레임워크에 따라 특정 패턴이 자연스럽게 많이 등장한다.

게임 개발 기준으로 보면 다음과 같은 형태를 자주 볼 수 있다.

Factory -> 객체 생성
State -> 캐릭터 상태 / AI
Strategy -> AI / 알고리즘 교체
Observer -> 이벤트 / UI 갱신
Command -> 입력 / 행동 / Undo / Replay
Facade -> 복잡한 시스템 접근 단순화
Adapter -> 외부 라이브러리 / 플랫폼 API 연결
Composite -> UI / Scene Graph / 트리 구조

그리고 실제 프레임워크 내부에는 개발자가 의식하지 않아도 여러 패턴이 이미 적용되어 있는 경우가 많다.


정리

디자인 패턴은 외워야 하는 23개의 클래스 구조가 아니다.

반복해서 발생하는 설계 문제와 그 해결 방법에 이름을 붙여 놓은 것이다.

객체 생성이 복잡하다 -> Factory / Builder
객체를 하나만 유지해야 한다 -> Singleton
서로 다른 인터페이스를 연결해야 한다 -> Adapter
복잡한 시스템을 단순하게 제공해야 한다 -> Facade
기능을 조합해서 추가해야 한다 -> Decorator
상태 변경을 여러 객체에 전달해야 한다 -> Observer
알고리즘을 교체해야 한다 -> Strategy
상태에 따라 행동이 달라진다 -> State
요청을 객체로 관리해야 한다 -> Command
객체 간 통신 관계가 복잡하다 -> Mediator

중요한 것은 패턴의 이름이 아니라 어떤 문제가 발생했고 왜 이 구조가 그 문제를 해결하는지 이해하는 것이다.

디자인 패턴을 제대로 이해하면 기존 코드를 패턴에 맞추는 능력보다, 새로운 문제를 봤을 때 적절한 객체 관계를 설계하는 능력이 생긴다.

그리고 실제 코드에서는 패턴을 그대로 복사하기보다 언어와 프로젝트 구조에 맞게 단순화해서 사용하는 경우가 많다. 패턴 자체보다 설계 의도를 이해하고 있는 쪽이 훨씬 오래 간다.