TIL_050: GoF(구조/행동패턴)

김펭귄·2025년 10월 22일

Today What I Learned (TIL)

목록 보기
50/93

오늘 학습 키워드

  • GoF(구조/행동패턴)

디자인 패턴 참고 자료

1. GoF 구조 패턴

  • 객체를 어떻게 구조, 조합, 연결할 것인가

어댑터 패턴

  • 서로 안 맞는, 호환되지 않는 인터페이스를 연결해주는 변환기

  • 예시 상황 : 언리얼의 벡터랑 외부 물리엔진의 벡터랑 같은 3D벡터인데, 둘이 호환이 안 됨. 그래서 같은 로직을 다르게 2번 써야함

// 언리얼 엔진의 벡터 클래스
class FVector {
public:
    float X, Y, Z;  // 벡터 요소를 public 멤버변수로
    
    float Size() const { /* 크기 */ }
};

// 외부 물리 엔진 (PhysX 같은)의 벡터 클래스
class PhysicsVec3 {
private:
    float coords[3];  // 벡터 요소를 private 멤버변수배열로
    
public:
    float getX() const { return coords[0]; }  
    float getY() const { return coords[1]; }  
    float getZ() const { return coords[2]; }
    float magnitude() const {  /*크기*/ }
};

// 언리얼 벡터를 처리
void MoveCharacter(FVector direction) {
    float speed = direction.Size();
}
// 물리 엔진 벡터도 처리
void MoveCharacterPhysics(PhysicsVec3 direction) {
    float speed = direction.magnitude();  
}
  • 처음엔 원하는 통합된 표준 인터페이스를 만듦.
// 공통 인터페이스
class IVector3D {
public:
    virtual float GetX() const = 0;
    virtual float GetY() const = 0;
    virtual float GetZ() const = 0;
    virtual float GetMagnitude() const = 0;
};
  • 이 인터페이스를 상속받아 감싸면서 표준화
// 언리얼 벡터를 위한 어댑터
class FVectorAdapter : public IVector3D {
private:
    FVector* vector;  // 언리얼 벡터
public:
    FVectorAdapter(FVector* vec) : vector(vec) {}
    
    // 인터페이스 구현. 상속받은 자료형에 따라
    float GetX() const override { return vector->X; }
    float GetY() const override { return vector->Y; }
    float GetZ() const override { return vector->Z; }
    float GetMagnitude() const override { return vector->Size(); }
};

// 물리 엔진 벡터를 위한 어댑터
class PhysicsVectorAdapter : public IVector3D {
private:
    PhysicsVec3* physicsVector;  // 물리 엔진 벡터
public:
    PhysicsVectorAdapter(PhysicsVec3* vec) : physicsVector(vec) {}
    
    float GetX() const override { return physicsVector->getX(); }
    float GetY() const override { return physicsVector->getY(); }
    float GetZ() const override { return physicsVector->getZ(); }
    
    float GetMagnitude() const override { return physicsVector->magnitude(); }
};

// 하나의 함수로 모든 벡터 처리 가능
void MoveCharacter(IVector3D* direction) {
    float x = direction->GetX();
    float y = direction->GetY();
    float z = direction->GetZ();
    float speed = direction->GetMagnitude();
}

// 사용 예시
void GamePlay() {
    // 언리얼 벡터(unrealVec) 사용
    FVectorAdapter unrealAdapter(&unrealVec);
    MoveCharacter(&unrealAdapter);
    
    // 물리 엔진 벡터(physicsVec) 사용
    PhysicsVectorAdapter physicsAdapter(&physicsVec);
    MoveCharacter(&physicsAdapter);  // 이것도 잘 동작!
}
  • 새로 만든 어댑터 인터페이스만 알면, 라이브러리가 다르더라도, 그 내부를 모르더라도 동일한 규칙으로 사용 가능

  • 나중에 새로운 벡터가 또 생기더라도, 똑같이 인터페이스 이용해서 해주면 되어서 편함

    • 적합한 경우

      1. 호환성이 필요해 유연성을 요구할 때 정말 좋음
      2. 옛날 시스템에 사용하더라도 호환성 좋음(레거시 시스템)
      3. 팀 협업에도 좋음
    • 피해야 할 경우

      1. 간접 호출이므로, 자주 사용되어 계산에 계속 쓰이면은 점점 성능 하락됨
      2. 주석 없으면 팀원이 처음에 보고 이건 뭐지 함
      3. 어댑터와 반대로 변환해주는 컨버터가 필요하다면, 또 컨버터 필요하게 됨

데코레이터 패턴

  • 객체에 새로운 기능을 동적으로 추가해 줄 수 있는 패턴

  • 원본 객체는 보존한 채로 냅두고, 계속 겹겹이 감싸며 기능을 추가

  • 예시 상황 : 기본 무기에 인챈트를 이용하여 여러 효과를 추가해주는 상황

// 상속으로 모든 조합 만들기
class Sword {};
class FireSword : public Sword {};           // 화염 검
class IceSword : public Sword {};            // 얼음 검
class LightningSword : public Sword {};      // 번개 검

// 2개 조합
class FireIceSword : public Sword {};        // 화염+얼음
class FireLightningSword : public Sword {};  // 화염+번개
class IceLightningSword : public Sword {};   // 얼음+번개

// 그 이상의 조합들... 10종류만 해도 1024개의 클래스 생성해야함
  • 상속으로 모든 조합 해버리면, 객체가 너무 많아지고 런타임으로 기능 추가 제거도 안 됨

  • 데코레이터 패턴으로 먼저 모든 무기의 인터페이스를 정의하고, 앞으로 확장될 기능들을 추상화

  • 이 인터페이스를 구체화한 base클래스를 생성

// 모든 무기가 가져야 할 기본 기능
class IWeapon {
public:
    virtual float GetDamage() const = 0;        // 데미지
    virtual float GetAttackSpeed() const = 0;   // 공격 속도
    virtual FString GetDescription() const = 0;  // 설명
};

// 기본 검
class BasicSword : public IWeapon {
public:
    float GetDamage() const override { return 50.0f; }
    float GetAttackSpeed() const override { return 1.0f; }
    FString GetDescription() const override { return "Basic Sword"; }
};
  • 그리고 데코레이터 객체를 만들어 감싸줌
// 모든 인챈트의 부모 클래스
class WeaponDecorator : public IWeapon {
protected:
    IWeapon* baseWeapon;  // 무기 다형성
    
public:
    WeaponDecorator(IWeapon* weapon) : baseWeapon(weapon) {}
    
    virtual ~WeaponDecorator() { delete baseWeapon;}
    
    // 기본 동작은 그냥 전달 (위임)
    float GetDamage() const override { return baseWeapon->GetDamage(); }
    float GetAttackSpeed() const override { return baseWeapon->GetAttackSpeed(); }
    FString GetDescription() const override { return baseWeapon->GetDescription(); }
};

// 화염 인챈트
class FireEnchantment : public WeaponDecorator {
private:
    float fireDamage = 20.0f;  // 화염 추가 데미지
    
public:
    FireEnchantment(IWeapon* weapon) : WeaponDecorator(weapon) {}
    
    // 기존 기능에 인챈트 기능 추가
    float GetDamage() const override {
        return baseWeapon->GetDamage() + fireDamage;
    }
    
    FString GetDescription() const override {
        return baseWeapon->GetDescription() + " [Fire +20]";
    }
};
// 얼음, 번개... 다른 것들도 마찬가지로 추가
  • 사용 방법
void CreateMyWeapon() {
    // 기본 검 생성 "Basic Sword - 데미지: 50.0"
    IWeapon* mySword = new BasicSword();
    
    // 화염 인챈트 추가 "Basic Sword [Fire +20] - 데미지: 70.0"
    mySword = new FireEnchantment(mySword);
    
    // 번개 인챈트 추가 
    // "Basic Sword [Fire +20] [Lightning +25, Speed +20%] - 데미지: 95.0, 공속: 1.2"
    mySword = new LightningEnchantment(mySword);
}
  • 새 인챈트가 추가될 때마다, 기존 무기 base클래스를 수정하는게 아니라, 감싸주는 것

    • 적합한 경우

      1. 기존 객체를 바꾸는게 아니라, 감싸면서 기능만 추가라서 런타임에 가능
      2. 런타임에 삭제도 가능
      3. 상속은 컴파일 시점에 기능이 정해지므로 안 됨
      4. 조합 순서에 따라 결과가 달라지므로 자유도를 원할 때
      5. 인챈트 시스템, 버프 시스템, UI 스타일 중첩, 스킬 강화
      6. 상속으로 1024가지 객체 만드는 것보다, 훨씬 좋음
    • 피해야 할 경우

      1. 우리가 감쌀 때, new로 만들기 때문에 삭제하면 아예 base객체가 사라짐. 스마트 포인터로 잘 관리해야함.
      2. 감싸는 객체가 많아질 수록 체인이 길어지는 것이라 디버깅할 때 찾기도 힘듦. 로그 출력을 잘 해줘야 추적이 쉬움
      3. 기능이 고정적이거나 조합이 많지 않은 경우는 애초에 필요 없음
      4. 성능이 극도로 중요한 경우

퍼사드 패턴

  • 복잡한 내부시스템을 외부에서는 한 눈에 잘 보이도록 간단한 인터페이스로 통합하여 제공

  • 추상화와 비슷

  • 문제 상황 : 게임 시작 시 수많은 시스템을 초기화하는데 복잡하고 순서도 중요한 상황

void StartGame() {
    // 1. 렌더링 시스템 초기화
    RenderingSystem* renderer = new RenderingSystem();
    renderer->Initialize();
    renderer->LoadShaders();            // 셰이더 로딩
    renderer->SetupRenderTargets();     // 렌더 타겟 설정
    // 렌더링 시스템 초기화 함수들.. //
    
    // 2. 오디오 시스템 초기화
    // 3. 입력 시스템 초기화
    // 4. 리소스 로딩
    // ... 더 많은 시스템들 초기화 과정
}
  • Facade 패턴은 시스템 마다 자신만의 초기화와 의존시스템이 있음

  • 세부적인 초기화 순서나 방법은 각 시스템 자신만 알면 됨

// 렌더링 시스템 - 그래픽 담당
class RenderingSystem {
public:
    void Initialize() {
        LoadShaders();
        SetupRenderTargets();
        // 나머지 과정 계속 호출
    }
    
    void LoadShaders() { /* ... */ }
    void SetupRenderTargets() { /* ... */ }
    // 나머지 초기화 함수들..
};

// 오디오 시스템 - 소리 담당
class AudioSystem {
	// 마찬가지로 초기화 구현
};
// 그 외 시스템들도 마찬가지...
  • 그리고 Facade라는 거대한 객체가 각 서브시스템을 내부에 보유

  • Facade가 보유하고 나중에 위임시킴

  • 그래서 외부에서 얘한테 start를 호출하면 이 객체가 나머지 일을 처리

class GameSystemFacade {
private:
    // 모든 복잡한 시스템들은 내부에 숨기기
    RenderingSystem* renderer;
    AudioSystem* audio;
    
public:
    // Facade가 모든 시스템을 관리
    GameSystemFacade() {
        renderer = new RenderingSystem();
        audio = new AudioSystem();
    }
    
    // 메모리 정리도 Facade가 알아서
    ~GameSystemFacade() {
        delete renderer;
        delete audio
    	// 나머지 시스템들..
    }
    
    // 싱글게임 초기화
    void StartSinglePlayerGame() {
        renderer->Initialize();
        audio->Initialize();
        input->Initialize();
    }
    
    // 멀티플레이어 게임 초기화
    void StartMultiplayerGame() {
        // 멀티플레이어는 네트워크도 필요
        renderer->Initialize();
        audio->Initialize();
        input->Initialize();
        network->Initialize();  
};

// 실제 게임 시작하는 방법
void NewWayToStartGame() {
    GameSystemFacade* gameFacade = new GameSystemFacade();
    
    // 싱글플레이어 함수 하나로 호출
    gameFacade->StartSinglePlayerGame();
    
    // 멀티플레이어 함수 하나로 호출
    gameFacade->StartMultiplayerGame();
}
  • 외부 개발자 입장에서 복잡한 초기화 방법 몰라도 그냥 start만 호출하면 됨

    • 적합한 경우

      1. 복잡한 라이브러리를 간단하게 사용하고 싶을 때
      2. 여러 서브시스템을 조합해서 사용해야 할 때
      3. 클라이언트가 내부 구조를 몰라도 되게 하고 싶을 때
      4. 게임 초기화 / 종료 (세이브 / 로드)
    • 피해야 할 경우

      1. 세밀한 제어가 필요할 때
      2. 서브시스템이 자주 변경되는 경우
      3. 모든 기능을 다 하는 God Facade를 만들게 된다면 의미 없음
      4. 단일책임원칙에 맞게, 초기화/저장 등 각각 역할 분리해야함

2. GoF 행동 패턴

  • 객체들끼리 협업 하는 방법

옵저버 패턴

  • 한 객체의 상태가 변하면 모든 의존 객체들이 알림을 받고 자동으로 업데이트 됨 (1:N)

  • 문제 상황 : 플레이어 체력이 떨어지면 관련 객체들에게 일일이 다 알려야함

class Player {    
public:
    void TakeDamage(float damage) {
        health -= damage;
        
        // 모든 시스템을 직접 호출
        healthBar->UpdateHealth(health);
        audio->PlayDamageSound();
        // ...
    }
};
  • 옵저버는 한 객체의 상태를 지켜보는 객체
class IHealthObserver {
public:
    virtual void OnHealthChanged(float health) = 0;
};
  • 상태가 바뀌면, 다른 객체들에게 옵저버가 알림을 보냄

  • 플레이어가 모든 시스템에 직접 호출하게 되면 복잡해지고 의존성도 높아짐. 시스템 추가될 때마다도 수정해줘야함.

  • 옵저버 패턴은 플레이어가 그냥 "나 체력 깎임"을 옵저버에게 알리고, 옵저버가 다른 애들한테 알림.

  • 옵저버 인터페이스의 함수는 알림 받을 애들은 이 함수를 구현해야함. 플레이어는 구체적인 UI ,소리 타입 몰라도 그냥 이 인터페이스만 알면 됨.

class HealthBarUI : public IHealthObserver {
	// override
    void OnHealthChanged(float health) override { /**/ }
};

class AudioObserver : public IHealthObserver {
    void OnHealthChanged(float health) override { /**/ }
};

class Player {
private:
    float health = 100.0f;
    vector<IHealthObserver*> observers;
    
public:
    void AttachObserver(IHealthObserver* obs) {
        observers.push_back(obs);
    }
    
    void NotifyHealthChanged() {
        for (auto obs : observers) {
        	// 실제 동작방식 모르고 그냥 호출만 하면 됨
            obs->OnHealthChanged(health);
        }
    }
    
    void TakeDamage(float damage) {
        health -= damage;
        NotifyHealthChanged(); // 모든 관찰자에게 자동 알림
    }
};

// 사용 방법
player.AttachObserver(&ui);
player.AttachObserver(&audio);

player.TakeDamage(30); // UI와 Audio가 자동으로 반응
  • delegate는 이벤트나 동작을 한 객체가 하나의 대표객체에게 어떤 행동을 하라고 지시. 1:1 관계임. 옵저버는 1:N.

    • 적합한 경우

      1. 게임 상태 변화를 여러 시스템이 감지해야 할 때
      2. 느슨한 결합을 유지하면서 이벤트를 전파하고 싶을 때
      3. 이벤트 기반 시스템: 아이템 획득, 퀘스트 완료, 레벨업 등의 이벤트 발생 시
      4. 실시간 알림: 길드 채팅, 친구 접속, 경매장 알림 등
      5. 성취/업적 시스템: 특정 조건 달성 시 여러 시스템이 반응해야 할 때
    • 피해야 할 경우

      1. 굉장히 빈도가 높은 알림은 쓸모 없음
      2. 매 틱마다 호출되어야 하는 이벤트를 수백개의 옵저버에게 알리는건 비효율적
      3. Observer 내부에서 무거운 연산을 수행하는 경우
      4. 체인 반응: Observer가 또 다른 이벤트를 발생시켜 무한 루프 위험
      5. 즉시 처리 필요: 이벤트 발생과 동시에 즉시 처리되어야 하는 경우

커맨드 패턴

  • 함수(행동)를 변수처럼 저장하고, 되돌릴 수도 있음
// Command 인터페이스
class ICommand {
public:
    virtual void Execute() = 0;
    virtual void Undo() = 0;
};

// Receiver (실제 작업을 수행하는 객체)
class Player {
public:
    int x = 0, y = 0;
    void MoveUp() { y++; }
    void MoveDown() { y--; }
};

// Concrete Commands
class MoveUpCommand : public ICommand {
private:
    Player* player;
    
public:
    MoveUpCommand(Player* p) : player(p) {}
    
    void Execute() override { player->MoveUp(); }
    void Undo() override { player->MoveDown(); }
};

// Invoker (커맨드 실행자)
class CommandManager {
private:
    vector<ICommand*> history;	// 로그
    int currentIndex = -1;
    
public:
    void ExecuteCommand(ICommand* cmd) {
        cmd->Execute();
        history.push_back(cmd);
        currentIndex++;
    }
    
    void Undo() {
        if (currentIndex >= 0) {
            history[currentIndex]->Undo();
            currentIndex--;
        }
    }
};

// 사용 방법
Player player;
CommandManager cmdManager;

auto moveCmd = new MoveUpCommand(&player);
cmdManager.ExecuteCommand(moveCmd); // 실행
cmdManager.Undo();                  // 되돌리기!
  • 커맨드 인터페이스를 통해 세부 동작이 달라도 같은 방식으로 호출 가능

  • 플레이어는 리시버. 실제 작업을 수행.

  • command 구체 객체는 간접적으로 리시버를 호출. 명령과 실제 작업하는 부분을 분리.

  • 커맨드 매니저는 명령을 전부 컨트롤할 객체로 히스토리를 만들어 실행할 내용 다 저장.

  • 행동을 data처럼 다루는 패턴. 행동은 함수가 아니라 객체가 됨

  • Enhanced Input System, Input Action이 이러한 패턴 방식

    • 적합한 경우

      1. 되돌리기가 필요한게임(턴제, 퍼즐)
      2. 키 설정 자유롭게 하기에 용이
      3. 행동을 큐에 저장해서 순차 실행하고 싶을 때
      4. 매크로나 리플레이 시스템을 만들 때
      5. AI의 행동을 계획하고 실행할 때 (저장하고 순차적 실행)
    • 피해야 할 경우

      1. 저장하고 이런거가 오버헤드여서 성능 떨어짐
      2. 모바일 같이 메모리가 제한적인 환경에서는 메모리 부족해짐
      3. 단순하거나, 지연 실행 불필요한 경우

상태 패턴

  • 문제 상황 : 객체의 내부 상태마다 동작 구현하려니 너무 복잡해짐
class Enemy {
    enum State { IDLE, CHASE, ATTACK };
    State currentState = IDLE;
    
public:
    void Update() {
        switch(currentState) {
            case IDLE:
                // ... //
                break;
            case CHASE:
                // ... //
                break;
            // 상태가 늘어날수록 복잡해짐...
        }
    }
};
  • 객체의 내부 상태가 변할 때, 객체의 행동도 함께 변하도록 하는 패턴

  • 상태를 별도의 클래스로 캡슐화하고 위임을 통해 행동을 변경

// State 인터페이스
class IEnemyState {
public:
    virtual void Update(class Enemy* enemy) = 0;
};

// Concrete States
class IdleState : public IEnemyState {
public:
    void Update(Enemy* enemy) override { /*..*/ }
};
class ChaseState : public IEnemyState {
public:
    void Update(Enemy* enemy) override { /*..*/ }
};

// 적 객체
class Enemy {
private:
    IEnemyState* currentState;
    
public:
    void ChangeState(/*..*/) {
    	// static으로 메모리 재사용
		static IdleState idle;
		static ChaseState chase;
        // 바꿀 state로 currentState 수정
    }
    
    void Update() {
        if (currentState) currentState->Update(this);
    }
};

// 사용 방법
Enemy enemy;
enemy.ChangeState(IdleState);

enemy.Update(); // 상태에 따라 다른 행동
  • 각 상태 자체가 객체가 되어 동작을 관리

  • state는 인터페이스를 상속받아 업데이트를 구현

  • enemy는 그냥 상태만 변경해주고 상태 객체에 동작을 위임. 현재 상태 전환 판단역시 각 상태 안에서. Update만으로 알아서 상태에 따라 동작하게 된다.

  • behaviour tree, AI에 적합. 언리얼의 애니메이션 블루프린트가 예시.

    • 적합한 경우

      1. AI같이 Idle, Chase, Attack 처럼 상태 다양할 때
      2. 게임 상태 (Menu, Playing, Paused, GameOver)를 관리할 때
      3. 플레이어 상태 (Normal, Stunned, Invisible, Flying)에 따라 행동이 달라질 때
      4. 복잡한 if-else 문을 깔끔하게 정리하고 싶을 때
    • 피해야 할 경우

      1. 상태 개수 적을 때

전략 패턴

  • 동일한 결과를 하는데 그 알고리즘을 전환해주는 패턴

  • 쉬움 난이도면 적이 랜덤하게 움직임. 어려움 난이도면, 적이 플레이어를 바로 추적.

  • 상태패턴이랑 구현 방식은 거의 동일

  • state는 객체 내부에서 state가 변경되는거고, 전략은 외부에서 전략을 수정하여 객체를 변경

  • 상태 패턴이든, 전략 패턴이든 여러 개의 상황이나 조건에 따라 동작이 달라져야 할 때 사용하면 좋음

  • 다양한 난이도, AI 행동패턴, 팀전술(공격적, 수비적), 무기별 공격 패턴

profile
반갑습니다

0개의 댓글