프록시 패턴이란, 특정 객체에 접근하고자 할 때 직접 접근하는 것이 아니라 대리자를 통해 접근하도록 하는 패턴이다.

 

프록시 패턴 (대리자 패턴)

아래의 코드를 보자.

class A
{
public:
    void Death() {}
    void Attack() {}
}

int main()
{
    A* NewA = new A():
    
    if(NewA != nullptr)
    {
        NewA->Attack();
    }
    
    if(NewA != nullptr)
    {
        NewA->Death();
        delete NewA;
        NewA = nullptr;
    }
    
    return 0;
}

 

먼저, A객체를 동적으로 생성해주었다.

 

이후, nullptr체크를 한 뒤 Attack함수를 호출해 주었다.

 

다음으로, nullptr체크를 한 뒤, Death함수를 호출한 뒤, NewA의 메모리를 해제하고 NewA가 가리키는 포인터를 nullptr로 변경해주었다.

 

이 코드에서 문제점을 찾아보자.

 

1. A객체를 알아야 한다.

2. 매번 nullptr 검사를 해주어야 한다.

3. 실제로 객체의 기능을 사용하기 이전부터 객체가 메모리에 존재한다.

 

A객체를 생성하고 그 멤버함수를 호출하기 위해선 당연히 A객체를 알아야 한다.

현재는 main함수에서 A객체의 멤버함수를 호출하고 있지만, 만약 B라는 다른 객체에서 A의 멤버함수를 호출하게 된다면 A객체와 B객체간의 결합도가 높아진다고 할 수 있다.

 

또한, A객체를 값으로 알고 있는 것이 아니라 주소값으로 알고 있기 때문에, 주소값에 접근할 때마다 nullptr검사 등의 유효성 검사를 매번 해주어야 한다. 이는 프로그래머 입장에선 상당히 번거로운 일이라고 할 수 있다.

 

또한, NewA가 실제로 사용되기 이전부터 객체를 생성하여 메모리에 존재하게 되는데, 위의 코드에선 생성한 뒤 바로 Attack함수를 호출하고 있지만, 그 시기가 늦어진다면 Attack 함수가 호출되기 이전까지 의미없는 데이터가 메모리 용량을 차지하고 있게 되는 것이다.

 

이러한 문제점들을 해결하기 위해 프록시 패턴을 사용할 수 있다.

 

아래의 코드를 보자.

class A
{
public:
    void Death() {}
    void Attack() {}
};

class AProxy
{
public:
    void Attack()
    {
        if(NewA == nullptr)
        { 
            NewA = new A();
        }
        
        NewA->Attack();
    }
    
    void Death()
    {
        if(NewA == nullptr)
        {
            std::cout << "A객체가 생성되지도 않았는데, 파괴하려 하였습니다." << std::endl;
            return;
        }
        
        NewA->Death();
        delete NewA;
        NewA = nullptr;
    }
    
private:
	A* NewA = nullptr;    
};

int main()
{
    AProxy* NewProxy = new AProxy();

    NewProxy->Attack();
    NewProxy->Death();

    return 0;
}

A의 기능을 사용하기 위해, A의 멤버함수를 직접 호출하는 것이 아니라 AProxy의 멤버함수를 호출하고 있다.

 

AProxy는 내부에서 A의 기능에 필요한 전처리를 한 뒤 A의 함수를 호출하고 있으며, 함수가 끝난 뒤 후처리가 필요하다면 후처리 또한 실행해주고 있다.

 

이로 인해, A의 기능을 사용할 때 매번 번거롭게 nullptr 검사 등의 전처리 코드를 작성할 필요가 없어지며, A의 객체를 직접 몰라도 AProxy 객체를 통해 A의 기능을 사용할 수 있게 된다. 이로 인해, A객체와 낮은 결합도를 유지한 채로 기능을 사용할 수 있게 된다.

 

또한, 전처리 과정에서 A 객체의 기능을 사용하고자 하는 객체의 권한을 파악하여 접근 허용 여부를 판별하게 되면, 보안 측면의 이점도 노려볼 수 있다. 

 

또한, AProxy의 Attack함수가 호출될 때 A객체를 생성하기 떄문에 A 객체의 생성 시점을 최대한 미룰 수 있으며, 이로 인해 조금 더 효율적인 메모리 사용이 가능해진다.

 

이처럼 A에 직접 접근하여 기능을 사용하지 않고, 다른 객체를 한 단계 거쳐서 A의 기능을 사용하는 디자인 패턴이 프록시 패턴이다. 

 

프록시 패턴에는 위와 같은 장점들이 있지만, 단점도 분명 존재한다.

 

1. 함수 호출 과정에 한 단계가 추가되는 만큼, 성능이 하락할 수 있다.

2. A객체 뿐만 아니라 AProxy 객체도 메모리에 존재해야 하므로, 메모리 사용량 측면에선 비효율적일 수 있다.

3. 함수 호출 과정에 한 단계가 추가되는 만큼, 코드가 복잡해지고 가독성이 떨어질 수 있다.

 

프록시 패턴의 장단점

장점
1. 객체 생성 시점을 뒤로 미룰 수 있다.
2. 전처리, 후처리 등을 기능과 묶어 사용할 수 있다.
3. 객체 간의 결합도를 낮출 수 있다.
4. 보안성을 향상시킬 수 있다.

 

단점
1. 메모리 사용량이 증가한다.
2. 속도 측면의 성능이 하락할 수 있다.
3. 코드가 복잡해지고 가독성이 저하될 수 있다.

 

발행 / 구독 패턴이란, 두 객체의 상호작용을 대리자를 통해 진행하는 패턴이다.

언리얼 엔진의 Delegate에도 사용되었다고 하며, 대표적인 객체지향 디자인 패턴 중 하나이다.

 

발행  구독 패턴

잡지사 A가 있다고 해보자.

 

몇몇 사람들은 잡지사 A의 신간이 발매되면, 해당 신간을 배송받기 위해 구독 서비스를 가입했다.

그렇다면, 잡지사 A는 신간이 발매되면 구독 서비스에 가입한 모든 사람들에게 잡지를 배송해야 한다.

 

이 과정을 코드로 구현해보자.

 

class Subscriber
{
public:
    void Read(int _Num)
    {
        std::cout << _Num << "번째 구독자는 신간 잡지를 읽었습니다." << std::endl;
    }
};

class Person1 : public Subscriber
{

};

class Person2 : public Subscriber
{
};

class MagazinePub
{
public:
    void ReleaseNewMagazine()
    {
        std::cout << "신간 잡지가 발매되었습니다." << std::endl;
        DeliverMagazine();
    }

    void DeliverMagazine()
    {
        for (int i = 0; i < Subs.size(); i++)
        {
            std::cout << i + 1 << "번째 구독자에게 신간 잡지를 배송했습니다." << std::endl;
            Subs[i].Read(i + 1);
        }
    }

    void AddSubScriber(Subscriber& _SubScriber)
    {
        Subs.push_back(_SubScriber);
    }

private:
    std::vector<Subscriber> Subs;
};

int main()
{
    MagazinePub Publisher;

    Person1 SubScriber1;
    Person2 SubScriber2;

    Publisher.AddSubScriber(SubScriber1);
    Publisher.AddSubScriber(SubScriber2);

    Publisher.ReleaseNewMagazine();

    return 0;
}

 

이런 식으로 구현할 수 있을 것이다.

Publisher는 모든 Subscriber를 자료구조에 저장한 뒤, 신간잡지가 발매되면 모든 SubScriber의 함수를 호출하는 것이다.

 

하지만, 이 방식은 좋은 방법이 아니다.

 

Publisher와 Subscriber는 서로 의존성이 생기며, 결합도가 높아진다.

또한, Publisher의 구독을 하려면 반드시 Subscriber 클래스를 상속받아야만 한다.

 

그렇다면, 이런 방법은 어떨까?

 

별도의 클래스를 하나 만들어놓은 뒤, 해당 클래스를 통해 Publisher 와 Subscriber가 소통하도록 하는 것이다.

예를 들어, 잡지사와 구독자 사이에 대행업체가 하나 생겼다고 가정해보자.

 

구독자는 대행업체를 통해, 구독 의사를 알리며 대행업체는 구독자들의 정보를 보관하게 된다.

잡지사는 신간이 발매되면, 대행업체에 신간 발매 소식을 전한 뒤 신간 잡지를 대행업체에 보내준다.

이후, 대행업체는 보유한 정보를 기반으로 모든 구독자들에게 신간 잡지를 뿌려주는 것이다.

 

이를 더 쉽게 이해하기 위해, 다른 예시를 하나 들어보겠다.

 

어떤 보스 몹이 소환되어 있다고 해보자. 해당 보스몹은 작은 몬스터 세마리를 소환하였다.

그리고, 보스몹이 사망하였다면 생존해있는 작은 몬스터 세마리도 함께 소멸해야 할 것이다.

이 때, 이를 구현하기 위해 일반적으로 생각할 수 있는 아이디어는 위에서 코드로 적은 것과 동일할 것이다.

 

작은 몬스터의 포인터를 모두 자료구조에 저장해놓은 뒤, 사망하는 순간 모든 작은 몬스터들의 사망 함수를 호출하는 것이다. 하지만, 위에서 말했듯이 이건 좋은 방법이 아니다.

 

모든 작은 몬스터의 자료형이 다르다면, 보스 몬스터는 모든 자료형을 알아야 할 것이며 인터페이스를 사용한다면 모든 작은 몬스터들이 이 한 번의 소통을 위해 인터페이스를 추가로 상속받아야 한다. 

 

이를 아래와 같은 방법으로 해결해보자

 

class Delegate
{
public:
    void Bind(std::function<void()> _Func)
    {
        Funcs.push_back(_Func);
    }

    void Execute()
    {
        for (int i = 0; i < Funcs.size(); i++)
        {
            Funcs[i]();
        }
    }

private:
    std::vector<std::function<void()>> Funcs;
};

 

먼저, 발행구독 패턴의 핵심인 Delegate를 보자.

 

내부에는 함수포인터를 저장한 배열이 있다.

그리고, 멤버함수에는 함수포인터를 자료구조에 추가하는 Bind 함수와, 모든 Funcs의 함수포인터를 실행하는 Execute가 있다.

 

이처럼, 함수포인터를 사용하여 외부에서 함수를 Funcs에 저장하기 때문에 자료형이 수만가지여도 Delegate는 손쉽게 대응할 수 있다.

 

//Subscriber
class MiniMob1
{
public:
    void Die()
    {
        std::cout << "MiniMob1 이 죽었습니다." << std::endl;
    }
};

//Subscriber
class MiniMob2
{
public:
    void Die()
    {
        std::cout << "MiniMob2 가 죽었습니다." << std::endl;
    }
};

//Subscriber
class MiniMob3
{
public:
    void Die()
    {
        std::cout << "MiniMob3 이 죽었습니다." << std::endl;
    }
};

작은 몬스터 3마리의 클래스를 이렇게 선언했다고 해보자.

각 Die함수들을 Delegate에 Bind해줄것이다.

 

//Publisher
class Boss
{
public:
    void SetDelegate(Delegate* _Delegate)
    {
        MyDelegate = _Delegate;
    }

    void Die()
    {
        std::cout << "Boss가 죽었습니다. " << std::endl;
        
        if (MyDelegate != nullptr)
        {
            MyDelegate->Execute();
        }
    }

private:
    Delegate* MyDelegate = nullptr;
};

그리고 보스 몬스터는 사망하는 순간 해당 델리게이트의 Execute를 호출함으로써 델리게이트에 Bind된 함수를 모두 실행해줄 것이다.

 

메인함수를 보자.

int main()
{
    Delegate NewDelegate;

    Boss NewBoss;

    MiniMob1 NewMob1;
    MiniMob2 NewMob2;
    MiniMob3 NewMob3;

    NewBoss.SetDelegate(&NewDelegate);
    
    NewDelegate.Bind(std::bind(&MiniMob1::Die, &NewMob1));
    NewDelegate.Bind(std::bind(&MiniMob2::Die, &NewMob2));
    NewDelegate.Bind(std::bind(&MiniMob3::Die, &NewMob3));

    NewBoss.Die();

    return 0;
}

 

델리게이트를 선언한 뒤, 작은 몹들은 델리게이트에 각 함수를 bind해주고 있다.

그리고 마지막에 보스의 Die가 호출되었다.

결과는 이렇다.

 

보스몹은 모든 작은 몬스터를 알지 못하고, 직접 함수를 호출해주지도 않았다.

하지만, 보스가 사망하는 순간 모든 작은 몬스터들도 사망한 것을 확인할 수 있다.

 

이처럼, Delegate 클래스와 같은 대리자를 별도로 두고 해당 클래스를 통해 소통하는 것이 발행 구독 패턴이다.

이로 인해, 보스몹과 작은 몬스터들은 결합도와 의존성이 매우 낮아지며, 객체지향적으로 더 완성도 있게 기능을 설계할 수 있게 된다.

 

 

그림으로 보면, 이런 관계가 형성되는 것이다.

이처럼, 중간에 대리자 클래스를 두어 서로 다른 클래스간의 소통을 대신 진행하는 패턴을 구독/발행 패턴이라고 한다.

컴포넌트 디자인 패턴은 게임 개발에서 굉장히 중요하게 사용되는 디자인 패턴이다.

물론, 다른 분야에서도 자주 사용되겠지만 언리얼 엔진, 유니티 등의 상용엔진에서 핵심적으로 사용되는 패턴이기 때문에, 반드시 알아두어야 한다.

 

컴포넌트 패턴이란, 코드의 재활용성을 높여줌과 동시에 커플링 문제를 해결하기 위한 디자인 패턴이다.

 

일반적으로 우리가 어떠한 기능을 구현하고자 하면, 해당 기능을 객체 내부에 정의하게 된다.

예를 들어, 플레이어가 이동하는 기능을 구현한다고 해보자.

class Player
{
public:
    void Move();
};

 

위 코드와 같이, 단순하게 객체 내부에 Move라는 함수를 선언한 뒤, 해당 함수를 필요할 때 호출해주면 된다.

하지만, 이동기능을 플레이어 뿐만이 아니라, 몬스터도 사용하고 싶다면?

 

일반적으로는 아래와 같이 상속을 활용하여 구현할 것이다.

class CharacterBase
{
    virtual void Move() {}
};

class Player : public CharacterBase
{
public:
    void Move() override;
};

class Monster : public CharacterBase
{
public:
    void Move() override;
};

 

이번엔, 공격 기능을 추가하고 싶다고 해보자.

아래와 같이 CharacterBase에 공격 함수를 추가하게 될 것이다.

class CharacterBase
{
    virtual void Move() {}
    virtual void Attack() {}
};

class Player : public CharacterBase
{
public:
    void Move() override;
    void Attack() override;
};

class Monster : public CharacterBase
{
public:
    void Move() override;
    void Attack() override;
};

 

이번엔, 몬스터가 종류가 2가지라고 생각해보자.

일반적으로는 두 몬스터 모두 아래와 같이 CharacterBase를 상속받게 될 것이다.

 

class CharacterBase
{
    virtual void Move() {}
    virtual void Attack() {}
};

class Player : public CharacterBase
{
public:
    void Move() override;
    void Attack() override;
};

class MonsterA : public CharacterBase
{
public:
    void Move() override;
    void Attack() override;
};

class MonsterB : public CharacterBase
{
public:
    void Move() override;
    void Attack() override;
};

 

여기서 몬스터 A가 인간형 몬스터, 몬스터 B는 고정형 몬스터라고 가정해보자.

몬스터 A는 이동에 관한 함수가 필요하지만 몬스터 B는 자리에 고정되어 움직이지 않는 타입이기 때문에 Move함수가 전혀 필요하지 않다.

 

하지만, 몬스터 B도 Attack 함수를 사용하기 위해선 CharacterBase를 상속받아야만 한다.

상속의 문제점은 여기서 발생한다.

 

해당 클래스를 상속받는 하위 클래스들이 정확히 어떤 함수들을 공통으로 가져야할지 정확히 예측할 수 없기 때문에, 특정 클래스에선 사용하지 않는 함수들이 발생할 수 밖에 없다.

 

또한, 지금은 CharacterBase 클래스에 Attack 과 Move만 있지만, 사운드 처리 기능이나 충돌 처리 등의 기능을 추가하게 되면 하나의 클래스가 너무 많은 기능와 책임을 가지게 되면서 유지보수가 굉장히 힘들고 복잡해진다. 

 

그렇다고, 공격에 관한 클래스와 이동에 관한 클래스를 따로 만들어서 다중 상속을 하게 되면 다이아몬드 문제가 발생할 가능성이 생겨버린다.

 

또한, 상속이라는 것은 두 클래스간의 결합도를 매우 높히기 때문에 부모 클래스에서 특정 로직을 수정했다가 자식 클래스를 몽땅 수정해야 하는 경우가 발생할 수도 있다.

 

이런 문제를 해결하기 위해 사용되는 패턴이 컴포넌트 패턴이다.

 

컴포넌트 패턴

아래 코드를 보자.

class MovementComponent
{
public:
    void Move() {}
    void Jump() {}
};

class AttackComponent
{
public:
    void BasicAttack() {}
    void ChargedAttack() {}
};

class Player
{
public:
    Player()
    {
        if (MyMovementComponent == nullptr)
        {
            MyMovementComponent = new MovementComponent();
        }

        if (MyAttackComponent == nullptr)
        {
            MyAttackComponent = new AttackComponent();
        }
    }

    ~Player()
    {
        if (MyMovementComponent != nullptr)
        {
            delete MyMovementComponent;
        }

        if (MyAttackComponent != nullptr)
        {
            delete MyAttackComponent;
        }
    }

    void Move()
    {
        if(MyMovementComponent != nullptr)
        {
            MyMovementComponent->Move();
        }
    }

    void BasicAttack()
    {
        if (MyAttackComponent != nullptr)
        {
            MyAttackComponent->BasicAttack();
        }
    }

private:
    MovementComponent* MyMovementComponent = nullptr;
    AttackComponent* MyAttackComponent = nullptr;
};

 

먼저, MovementComponent를 만들어서 그 안에 Move와 Jump함수를 선언해놓았다.

다음은 AttackComponent를 만들어서 그 안에 BasicAttack과 ChargedAttack을 선언해놓았다.

 

Player내부에선, 생성자에서 MovementComponent와 AttackComponent를 생성해주고 있으며, 해당 기능이 필요할 때 컴포넌트의 함수를 호출하여 기능을 사용하고 있다.

 

이 것이 컴포넌트 패턴이다. 내가 공격이 필요하다면 AttackComponent를 생성해서 사용하면 되고, 이동이 필요하지 않다면 MovementComponent를 안만들면 그만이다.

 

이렇게 컴포넌트 패턴으로 구현하게 되면, 상속관계에서 발생했던 문제들이 전혀 발생하지 않는다.

 

객체 내부에선 컴포넌트의 함수를 호출해서 사용하기만 하면 되기 때문에, 두 클래스 간의 결합도가 상속할때보다 훨씬 낮아진다. 또한, 컴포넌트끼리도 서로 모르는 상태로 기능을 구현할 수 있기 때문에, 컴포넌트 사이의 결합도 또한 매우 낮다.

 

컴포넌트 별로 특정 기능을 담당하여 구현하기 때문에, 필요한 컴포넌트만 생성해서 사용한다면 객체에서 필요없는 기능을 보유하는 일이 현저하게 줄어든다. 

 

기능을 수정할 때에도, 컴포넌트 클래스 내부의 코드만 수정하면 되기 때문에 유지보수 측면에서도 매우 유용하다.

 

언리얼 엔진에서는 렌더링과 관련된 부분인 SkeletalMeshComponent도 있고, 이동과 관련된CharacterMovementComponent도 있고, AI의 인지기능과 관련된 AIPerceptionComponent도 있다. 기능이 필요할 때마다, 해당 컴포넌트를 생성해서 사용하면 된다.

 

상속과 컴포넌트는 Is - A관계와 Has - A 관계라고도 부른다.

 

Is - A , Has - A

 

Goblin이라는 클래스가 Monster라는 클래스를 상속받았다고 해보자.

이 관계에서 Goblin은 곧 Monster라고도 할 수 있다.

Goblin is a monster. 라고 표현할 수 있기 때문에, 상속관계는 Is - A 관계라고도 부른다.

 

Player가 MovementComponent를 생성했다고 해보자.

이 컴포넌트를 사용하기 위해 Player는 컴포넌트를 멤버변수에 저장하여 사용할 것이다.

 

즉, Player는 MovementComponent를 보유하고 있는것이다.

Player has a MovementComponent.라고 표현할 수 있기 때문에 컴포넌트 구조는 Has - A 관계라고도 부른다.

 

상속과 컴포넌트는 각각의 장단점이 존재한다.

 

상속의 장단점

장점

  • 객체지향의 특징인 다형성을 구현할 수 있다.
  • 객체의 속성을 확실하게 표현할 수 있다. 

단점

  • 클래스 간의 결합도가 높아진다.
  • 부모 클래스의 코드를 수정할 때, 자식 클래스의 코드를 몽땅 수정해야 하는 경우가 발생할 수도 있다.
  • 부모 클래스에 결함이 있을 때, 이는 자식 클래스에도 결함으로 전파된다.

컴포넌트의 장단점

장점

  • 클래스간의 결합이 느슨하다.
  • 객체에서 필요없는 기능을 보유하게 되는 경우가 상대적으로 적다. 
  • 컴포넌트 끼리 서로 알 필요가 없다. (디커플링)
  • 상속에 비해, 캡슐화를 더욱 견고하게 할 수 있다.

단점

  • 컴포넌트 간의 상호작용을 구현하기가 까다롭다.
  • 사용하기에 따라, 오히려 코드가 복잡해지고 유지보수를 어려워지게 만들 수 있다.

 

컴포넌트 패턴도 상속도 잘 사용하면 너무나 좋은 것들이지만, 사실 잘 쓰기가 정말 까다로운 것 같다.

컴포넌트 패턴도 언뜻 보기엔 좋아보여도, 막상 구현하려고 하면 정말 고려해야 하는 것들이 너무너무 많다.

상용엔진을 사용하게 되면, 자주 접하게 되는 만큼 조금이라도 알고 사용하도록 하자.

 

객체지향 디자인 패턴에는 팩토리 메서드 패턴과 추상 팩토리 패턴이 있다.
두 패턴에 대해 알아보기 전에 먼저 심플 팩토리에 대해 알아보자.

 

심플 팩토리는 두 디자인 패턴의 기본이 되는 방식이다.

객체지향 원칙을 다소 위배하는 부분이 있어, 디자인 패턴으로 분류되지는 않고 일종의 관용구라고 생각하면 좋다.

그리고, 이 패턴의 단점을 해결한 패턴이 팩토리 메서드 패턴과 추상 팩토리 패턴인 것이다.

 

심플 팩토리란?

먼저, 이 패턴이 왜 나왔는지에 대해 이해해보자.

 

음료수를 마시고 싶은 손님과 이를 판매하는 상황을 예로 들어보겠다.

소비자는 Client클래스이며, 음료수는 GrapeBeverage, AppleBeverage 두 객체가 있다고 해보자.

 

일반적으로는 이렇게 음료수 객체를 생성할 것이다.

class Client
{
    void BuyBeverage()
    {
    	GrapeBeverage* GrapeBG = new GrapeBeverage();
    	AppleBeverage* AppleBG = new AppleBeverage();
    }
};

 

이렇게, new를 통해 객체를 직접적으로 생성하게 되면 객체지향적인 부분에서 문제가 생긴다.

Client 객체와 음료수 객체 간의 결합도가 높아진다는 것이다.


지금이야 코드가 간단하니 괜찮아 보여도 코드 길이가 길어진다고 가정해보면, GrapeBeverage, AppleBeverage 객체를 수정하게 될 때 그에 따라서 Client의 코드를 수정해야 하는 상황도 심심찮게 발생할 것이다.

 

이러한 결합도, 의존성을 제거하기 위해 만들어진 것이 심플 팩토리 패턴이다.

 

그렇다면 심플 팩토리 패턴은 어떤 방식으로 설계해야 할까?

class Beverage
{
public:
    virtual void Drink() = 0;
};

class GrapeBeverage : public Beverage
{
public:
    void Drink()
    {
        std::cout << "포도 주스를 마셨다." << std::endl;
    }
};

class AppleBeverage : public Beverage
{
public:
    void Drink()
    {
        std::cout << "사과 주스를 마셨다." << std::endl;
    }
};

 

먼저 이렇게 음료수를 상속 구조로 바꿔보자.

GrapeBeverage와 AppleBeverage는 모두 Beverage 클래스를 상속받고 있다.

 

이번엔, 이 음료수를 생성해주는 클래스를 만들어 볼 것이다.

class BeverageFactory
{
public:
    enum class BeverageType
    {
        Grape,
        Apple,
    };

    Beverage* CreateBeverage(BeverageType _InputType)
    {
        Beverage* ReturnBeverage = nullptr;

        switch (_InputType)
        {
        case BeverageType::Grape:
            ReturnBeverage = new GrapeBeverage();
            break;
        case BeverageType::Apple:
            ReturnBeverage = new AppleBeverage();
            break;
        }

        return ReturnBeverage;
    }
};

 

이렇게 enum타임만 입력받으면 내부에서 음료수는 만들어서 부모 클래스 타입으로 반환해주는 클래스를 만들게 된다면

Client클래스에선 BeverageFactory 클래스 하나만 알아도 모든 종류의 음료수를 다 만들 수 있는 것이다.

 

이제 위의 클라이언트 코드를 수정해보면

class Client
{
public:
    void BuyBeverage()
    {
        BeverageFactory* BGFactory = new BeverageFactory();

        Beverage* GrapeBG = BGFactory->CreateBeverage(BeverageFactory::BeverageType::Grape);
        Beverage* AppleBG = BGFactory->CreateBeverage(BeverageFactory::BeverageType::Apple);

        GrapeBG->Drink();
        AppleBG->Drink();
    }
};

 

Beverage와 BeverageFactory클래스만 알고 있다면,

음료수의 종류가 100개가 되든 200개가 되든 직접적인 결합 없이 생성할 수 있게 되는 것이다.

 

이 것이 심플 팩토리 패턴이다.

객체를 직접 알지 않아도 생성할 수 있도록 하는 것이다.

 

다만, 이 심플 팩토리 패턴의 경우엔 위에서 말했듯이 객체지향의 원칙을 위반하는 것이 있다.

개방-폐쇠 원칙에 위배된다.

 

왜냐면, 음료수를 추가할 때마다 CreateBeverage 내부의 함수를 고쳐야 한다.

물론 switch 분기 하나 추가하는 정도긴 하지만, 객체지향 원칙을 제대로 지키려면 개방에는 열려있어야 하지만 수정에는 닫혀있어야 한다.

 

이 문제를 커버하기 위해 나온 패턴이 팩토리 메서드 패턴과 추상 팩토리 패턴이다.

 

 

 

팩토리 메서드 패턴


팩토리 메서드 패턴은 심플 팩토리 패턴을 기반으로 설계된다.

먼저 어떻게 설계되는지 보자.

class BeverageFactory
{
public:
    virtual Beverage* CreateBeverage() = 0;
};

 

BeverageFactory를 위와 같이 추상 클래스로 선언해준다.

 

class GrapeBeverageFactory : public BeverageFactory
{
public:
    virtual Beverage* CreateBeverage() override
    {
        Beverage* ReturnBeverage = new GrapeBeverage();
        return ReturnBeverage;
    }
};

class AppleBeverageFactory : public BeverageFactory
{
public:
    virtual Beverage* CreateBeverage() override
    {
        Beverage* ReturnBeverage = new AppleBeverage();
        return ReturnBeverage;
    }
};

 

이후, 이렇게 BeverageFactory를 상속받은 클래스를 각각 만들고, 각 클래스가 하나의 음료만 담당하도록 하자.

Client에선 아래와 같이 사용한다.

class Client
{
public:
    void BuyBeverage()
    {
        BeverageFactory* GrapeBGFactory = new GrapeBeverageFactory();
        BeverageFactory* AppleBGFactory = new AppleBeverageFactory();

        Beverage* GrapeBG = GrapeBGFactory->CreateBeverage();
        Beverage* AppleBG = AppleBGFactory->CreateBeverage();

        GrapeBG->Drink();
        AppleBG->Drink();
    }
};

 

이런 식으로 각 음료당 하나의 클래스를 담당하여 Factory클래스를 구성하게 되면, 음료룰 추가할 때 객체의 코드를 구성할 필요가 없고 새로운 클래스를 하나 더 추가하면 된다.

 

즉 개방-폐쇄 원칙을 위반하지 않는 셈이 된다.

 

다만, 이 패턴의 단점은 음료의 수가 많아질수록 관리해야 하는 클래스가 점점 많아진다는 것이다.

그런 단점에도 불구하고 의존성 분리에 있어 효과적인 디자인 패턴이기 때문에 의외로 많은 곳에서 접할 수 있는 패턴이다.

 

 

 

추상 팩토리 패턴


추상 팩토리 패턴은 팩토리 메서드 패턴과 거의 유사한 패턴이다.

 

이번엔 음료수를 하나 사면 경품도 추가로 준다고 가정해보자.

포도 주스를 구매하면 포도 빵을 주고 사과 주스를 사면 사과 빵 준다고 가정해보겠다.

 

class Bread
{
    virtual void Eat() = 0;
};

class GrapeBread : public Bread
{
    virtual void Eat() override
    {
        std::cout << "포도 빵을 먹었다" << std::endl;
    }
};

class AppleBread : public Bread
{
    virtual void Eat() override
    {
        std::cout << "사과 빵을 먹었다" << std::endl;
    }
};

 

먼저 이렇게 빵을 선언해주자.

 

이후, BeverageFactory 클래스에 순수가상함수를 하나 추가해주겠다.

class BeverageFactory
{
public:
    virtual Beverage* CreateBeverage() = 0;
    virtual Bread* CreatePrize() = 0;
};

 

 

이후 이 클래스를 상속받은 하위 클래스들이 구현해주면 끝이다.

class GrapeBeverageFactory : public BeverageFactory
{
public:
    virtual Beverage* CreateBeverage() override
    {
        Beverage* ReturnBeverage = new GrapeBeverage();
        return ReturnBeverage;
    }

    virtual Bread* CreatePrize()
    {
        Bread* ReturnBread = new GrapeBread();
        return ReturnBread;
    }
};

class AppleBeverageFactory : public BeverageFactory
{
public:
    virtual Beverage* CreateBeverage() override
    {
        Beverage* ReturnBeverage = new AppleBeverage();
        return ReturnBeverage;
    }

    virtual Bread* CreatePrize()
    {
        Bread* ReturnBread = new AppleBread();
        return ReturnBread;
    }
};

 

언뜻 보기엔 팩토리 메서드 패턴과 대체 뭐가 다르지? 싶다.

다른 점은 하나이다. 연관된 객체들을 함께 관리할 수 있다는 것이다.

 

class Client
{
public:
    void BuyBeverage()
    {
        BeverageFactory* GrapeBGFactory = new GrapeBeverageFactory();
        Beverage* GrapeBG = GrapeBGFactory->CreateBeverage();
        Bread* GrapeBread = GrapeBGFactory->CreatePrize();
        
        GrapeBG->Drink();
        GrapeBread->Eat();

        BeverageFactory* AppleBGFactory = new AppleBeverageFactory();
        Beverage* AppleBG = AppleBGFactory->CreateBeverage();
        Bread* AppleBread = AppleBGFactory->CreatePrize();

        AppleBG->Drink();
        AppleBread->Eat();
    }
};

 

위와 같이, 하나의 팩토리가 가진 멤버함수를 사용하여 연관된 객체를 고민없이 생성할 수 있다.

 

예를 들어 캐릭터가 여러 종류 있고, 캐릭터 별로 따라다니는 펫의 종류가 다르다고 한다면

현재 캐릭터와 매칭되는 펫이 무엇인지 고민할 필요 없이 팩토리에서 펫을 만들어주는 함수만 호출하면 되는 것이다.

 

팩토리 메서드 패턴에서 더 캡슐화된 형식이라고 보면 될 것 같다.

 

여기까지 팩토리 메서드 패턴과 추상 팩토리 패턴에 대해 알아보았다.

다음 게시물에선 컴포넌트 패턴을 올려볼까 한다.

객체지향 디자인 패턴에는 싱글톤이라는 패턴이 있다.

 

싱글톤이란?

어떠한 클래스의 인스턴스가 프로세스 내에 단 1개만 생성되도록 제한하는 디자인 패턴.

 

예를 들면, 게임 내에서 플레이어가 있고 이 플레이어가 지니고 있는 스텟이 있다고 했을 때, 플레이어의 스텟 정보는 다음 레벨로 넘어가더라도 유지되어야 한다.

 

하지만, 플레이어 객체 내부에 스텟 데이터를 저장해두었다면 레벨이 바뀌는 순간, 객체는 파괴되어버리고 스텟 데이터는 사라지게 된다. 다음 레벨에서 스폰되는 플레이어 객체는 기존과는 다른 스텟 데이터를 가지고 있게 되는 것이다.

 

이러한 이유로 게임 프로그래밍에선 데이터를 가능한 분리하는 것이 좋다. 

그렇다면, 스텟 정보를 다른 클래스로 분리를 시켰는데 이 클래스가 여러개가 있어야 할까?

플레이어의 Hp, 공격력 등의 정보를 담은 클래스의 인스턴스는 게임에 1개만 있으면 충분하다. 2개 3개가 되면 메모리만 낭비될 뿐이다.

 

이런 상황에 데이터 정보를 담은 클래스의 인스턴스를 단 1개로 제한하기 위해 사용할 수 있는 디자인 패턴이 싱글톤 패턴이다.


어떻게 사용하는가?

방식은 여러가지가 있다.

먼저 가장 간단한 방식이다.

 

1. 이른 초기화

class Singleton
{
    private:
        Singleton() {}
        ~Singleton() {}
        
        static Singleton instance;

    public:
        static Singleton &GetIstance()
        {
            return instance;
        }
};

Singleton Singleton::instance;

 

생성자와 소멸자를 private로 선언하여, 외부에서 생성 및 소멸을 할 수 없도록 한다.

이후, static으로 전역 변수를 선언하여 인스턴스를 생성해준다.

 

다른 클래스에서 싱글톤 객체를 참조하고 싶다면, Singleton::GetInstance() 함수를 호출하여 참조자를 반환받아 사용한다.

 

아주 간단하게 객체를 단 1개만 생성되도록 해보았다.

소멸자와 생성자가 private이기 때문에 객체의 생성은 내부에서만 가능하다.

내부에서는 static으로 단 1개만 생성하였기 때문에 프로세스 내에는 인스턴스가 1개만 존재하게 된다.

 

하지만, 이 경우 문제가 몇가지 있다.

 

1. 전역 변수의 생성, 초기화 시기는 정확히 정의되어 있지 않다.

C++에서 전역변수를 여러개 선언하게 된다면, 이 객체들의 생성 시기는 정확하게 정해져 있지 않다.

A, B, SingleTon 이렇게 3개의 전역 변수를 선언했다고 가정하면

A->B->SingleTon 순서로 생성될 수도 있지만, B->SingleTon->A 의 순서로 생성될 수도 있는 것이다.

 

만약, A의 생성자에서 SingleTon을 참조했다고 가정해보자.

SingleTon->A->B 의 순서로 전역 객체가 생성되었다면 별 다른 문제가 없지만

A->SingleTon->B의 순서로 생성된다면 문제가 발생한다. A에서 참조하고자 하는 대상이 아직 존재하지 않기 때문이다.

 

이런 경우는 흔하지 않은 경우지만, 이른 초기화 방식이 안전하지 않음을 명확히 알고 있어야 한다.

 

2. 프로세스가 시작되는 순간부터 끝나는 순간까지 메모리를 계속 점유한다.

싱글톤 객체가 자주 사용되고 계속 참조된다면 상관이 없지만, 자주 사용되지 않는다고 한다면 이는 비효율적일 수 있다.

처음부터 전역변수로 생성되고 프로세스가 끝날 때 까지 계속 메모리 상에 존재하기 때문에 메모리 낭비가 발생할 수 있는 것이다.

 

이러한 문제점을 해결하기 위해 나온 두 가지 방식이 있다.


2. 늦은 초기화 - 다이나믹 싱글톤

class Singleton
{
    private:
        Singleton() {}     
        ~Singleton() 
        {
            delete Instance;
            Instance = nullptr;
        }
        
        static Singleton* Instance;

    public:
        static Singleton* GetIstance()
        {
            if(Instance == nullptr)
            {
                Instance =  new Singleton();
            }
            
            return Instance;
        }
};

Singleton* Singleton::Instance = nullptr;

 

먼저, 늦은 초기화의 한 종류인 다이나믹 싱글톤이다.

 

이 방식은 GetInstance가 호출되는 시기에,  Instance이 nullptr인지 아닌지를 검사하게 된다.

Instance가 nullptr이라면 그 시기에 객체를 생성하여 참조할 수 있게 해준다.

즉, 이른 초기화 방식에서 발생할 수 있었던 생성 시기의 문제가 해결되는 것이다.

 

또한, GetInstance()를 호출하기 전까지 객체가 생성되지 않기 때문에 메모리를 효율적으로 사용할 수 있다.

 

다만, 이 방식도 두 가지 문제가 있다.

 

1. Thread-Safe 하지 않다는 것

 

멀티스레딩 환경에서 서로 다른 스레드가 동시에 GetInstance()함수를 호출한다고 가정해보자.

A스레드가 GetInstance()를 호출하고 Instance가 nullptr임을 확인하고 동적할당을 실행하였다.

 

동적할당이 완료되기 전 B스레드가 GetInstance()를 호출한다면 여전히 Instance는 nullptr이기 때문에

B스레드 또한 동적 할당을 실행할 것이다.

 

즉, 객체를 한개만 만들기 위한 디자인 패턴임에도 불구하고 멀티스레딩 환경에선 객체가 2개 이상 생성될 수 있다는 것이다. 물론, lock_guard, mutex등을 사용하여 방지할 수는 있지만 이러한 경우 성능 저하를 유발할 수 있기 떄문에 좋은 방법은 아니다.

 

2. 소멸자 호출 시기가 불 분명하다는 것

 

위에서 말했듯이 전역변수는 생성 시기가 명확하지 않다. 당연히, 소멸 시기또한 명확하지가 않다.

소멸자에서 메모리 해제를 해주고 있는데, 이는 소멸 시기에 따라 메모리 누수로 기록될 수 있다.

그렇기 때문에 프로세스가 끝나기 전에 명시적으로 메모리 해제 함수를 호출하여 직접 delete를 해주어야 하고, 여기서 번거로움이 발생할 수 있다.

 

이를 해결하기 위한 또 하나의 늦은 초기화 방식이 있다.

 

2. 늦은 초기화 - 마이어스 싱글톤

class Singleton
{
    private:
        Singleton() {}     
        ~Singleton() {}

    public:
        static Singleton& GetIstance()
        {
            static Singleton Instance;
            return Instance;
        }
};

 

이렇게 지역 스태틱 변수를 활용하는 것이다.

이는 동적할당을 하지 않기 때문에, 메모리 누수로 기록될 여지도 없으며, static 변수는 두 개 이상 생성 될 수 없기 때문에 thread-safe하다.

 

또한, GetInstance()가 호출되기 전까지 객체가 생성되지도 않으므로 메모리 낭비 또한 걱정할 필요 없는 방식이다.

 

하지만, 여전히 한 가지 문제는 남아있다.

 

소멸자 호출 시기가 불 분명하다는 것

 

위에서 여러 번 언급했듯이, 전역 객체는 소멸과 생성의 시기가 정확하지가 않다. 이렇게 GetInstance() 호출 시 생성되는 방식으로 생성 시기의 불분명함으로 발생하는 문제는 해결할 수 있지만, 소멸 시기의 불분명함으로 발생하는 문제는 해결할 수 없다. 

다른 전역 객체의 소멸자에서 GetInstance()를 호출하는 시기에 SingleTon 객체가 소멸했다면 여기서 문제가 발생한다.

(물론 이런 상황은 정말 흔하지는 않겠지만...)

 

 

이러한 소멸자의 문제를 해결하기 위해, 피닉스 싱글톤이라는 좀 더 어려운 방식이 존재한다.

 

3. 피닉스 싱글톤

class PhoenixSingleton
{
private:    
    static bool bDestroyed;   
    static PhoenixSingleton* pIns;     
    PhoenixSingleton() {};    
    PhoenixSingleton(const PhoenixSingleton& other);    
    ~PhoenixSingleton()   
    {        
        bDestroyed = true;    
    }
    
    static void Create()   
    {        
        static PhoenixSingleton ins;        
        pIns = &ins;    
    }
    
    static void KillPheonix()   
    {        
        pIns->~PhoenixSingleton();    
    }
    
public: 
    static PhoenixSingleton& GetInstance()   
    {        
        if (bDestroyed)        
        {            
            new(pIns) PhoenixSingleton;            
            atexit(KillPheonix);           
            bDestroyed = false;       
        }       
        else if (pIns == NULL)      
        {           
            Create();       
        } 
        
        return *pIns;  
    }
}; 

bool PhoenixSingleton::bDestroyed = false;
PhoenixSingleton* PhoenixSingleton::pIns = NULL;

 

 

싱글톤 객체가 소멸된 상태에서 호출되면 다시 객체를 되살리는 방식이다.

전역 변수인 bDestroyed를 이용하여 객체가 살아있나 죽어있나를 판단한 뒤, 객체가 죽어있는 상태라면

placement new를 사용하여 객체를 다시 되살린다. (전역 변수는 소멸 시기에 메모리를 초기화하지 않는다고 한다.)

이후 atexit함수를 이용하여, 프로그램 종료 시기에 소멸자가 호출될 수 있도록 한다.

placement new로 할당한 메모리는 delete로 해제하는 것이 아니라 소멸자를 직접 호출해야 한다.

 

이 방식도 완전히 문제가 없는 것은 아니라는데, 사실 그 정도까지 들어갈 필요가 있나 싶다.

피닉스 싱글톤을 사용하고 나서도 문제가 발생한다면, 싱글톤 구조를 고민하기보단 코드 설계를 제대로 했는가를 먼저 고민해보도록 하자...


 

이렇게 여러가지 싱글톤을 알아보았는데, 싱글톤은 사용하기에 따라 편리하고 유용한 디자인 패턴이기도 하지만

몇 가지 단점들로 인해 안티 패턴이라고 여겨지기도 한다.

 

1. 객체의 결합도가 높아진다. (의존성이 높아진다.)

싱글톤 같은 경우, 전역 함수를 통해 인스턴스를 받아온 뒤, 정적 메소드를 직접 호출하게 된다.

ex) InfoManager::GetInstance()->GetLevel();

 

이런 방식의 경우, 객체 간의 결합도가 높아지게 되고 싱글톤 객체의 코드를 수정하게 되면 이를 참조하는 수많은 객체의 코드를 모두 수정해야 할 수도 있다. 객체지향적인 측면에서 싱글톤 패턴은 그리 좋은 설계는 아니라는 것이다.

 

2. 멀티 스레딩 환경에서 안정적이지 못하다.

 

싱글톤 방식같은 경우, 하나의 객체를 여기저기서 참조하게 된다. 읽기만 하는 것이 아니라 경우에 따라 쓰기를 할 때도 있다. 멀티 스레딩 환경에선 하나의 메모리 영역을 여기저기서 공유하게 되니, 동기화에 대한 중요성이 커지게 된다. 이를 해결하기 위해, 여기저기에 동기화 처리를 해주게 되면 성능이 많이 저하될 수 있다. 

 

싱글톤 패턴은 이러한 문제점들 때문에 효율적으로 설계하기 참 까다로운 패턴이다.

구현이 간단하다고 해서 아무렇게나 사용하지 말고, 사용에 있어 고민을 많이 해보아야 할 듯 하다.

 

 

+ Recent posts