본문 바로가기
개발/게임 디자인 패턴

게임 디자인 패턴 9. 타입 객체 패턴 (Type Object Pattern)

by 석시 2023. 11. 2.



저번 시간 하위 클래스 샌드박스의 정리를 위해 해당 내용을 책에서 읽던 도중, ”요즘은 여러 프로그래머 집단에서 ‘상속’을 나쁘게 생각한다.”는 내용이 있었다.

왜 상속을 안좋게 생각하는 거지?

상속으로 코드의 재사용성을 늘릴 수 있는 것 아닌가? 라고 생각했었다.

그러나 우리가 반사적으로 생각하는 상속 구조의 클래스들 역시 유지보수 면에서 문제가 발생하기 때문에 어느정도 특성을 공유하는 클래스들을 구현할 때 개발자들은 상속을 통해 만드는 것이 아니라 지금 소개하는 타입 객체 패턴을 통해 만들게 된다.

지금부터 일반적인 상속 구조와 타입 객체 패턴 간의 차이점을 위주로 타입 객체 패턴에 대해 알아보도록 하자.


일반적인 상속 구조..?

앞서 말한 일반적인 상속 구조의 구현부터 이야기를 해보자.

Monster 클래스를 부모로 두고 여러 종류의 몬스터 클래스를 구현한다고 하면 다음과 같이 구현할 수 있다.

public class Monster
{
    private int _hp;

    public virtual void Attack()
    {
        Debug.Log("몬스터의 공격!");
    }

    protected Monster(int startingHealth)
    {
        _hp = startingHealth;
    }
}

public class Dragon : Monster
{
    public Dragon() : base(230) { }
    
    public virtual void Attack()
    {
        Debug.Log("드래곤의 공격!");
    }
}

public class Troll : Monster
{
    public Troll() : base(130) { }
    
    // 대충 이런 식으로....?
}

이 때 Attack() 함수를 오버라이드해서 다양한 방식으로 몬스터의 공격을 구현하게 된다.

이제 다음과 같은 상황에 어떻게 되는지 보자.

상황 1. 작성해야 하는 몬스터들의 종류가 수백가지라면?

반복해서 하위 클래스를 작성하고 컴파일해야한다.

상황 2. 특정 몬스터의 체력과 같은 스탯을 수정해야한다면?

스크립트에 들어가 수치를 변경한 뒤 다시 컴파일해줘야 한다!

코드 관리 편하게 하자고 객체지향을 추구하는 것 아니었나?

오히려 수정하기가 더 복잡하다!

이러한 문제들은 우리가 개발을 하면서 반사적으로 몬스터의 종족 개념을 클래스의 상속 관계로 구현하려 했기 때문에 생긴 문제이다.


타입 객체 패턴의 기본적인 형태

앞서 언급한 몬스터의 종족 개념을 상속 관계가 아니라 종족 클래스로 구현하는 것은 어떤가?

다음과 같이 말이다.

using System;

public class Breed
{
    public int Health { get; private set; }
    public Action AttackAction;
    
    Breed(int health, Action attack)
    {
        Health = health;
        AttackAction = attack;
    }
}

public class Monster
{
    private int _health;
    private Breed _breed;

    public Monster(Breed breed)
    {
        _health = breed.Health;
        _breed = breed;
    }

    public void Attack() => _breed?.AttackAction();
}

위 코드의 구성은 이렇게 된다.

Breed라는 타입 객체 (Type Object) 클래스 와 Monster라는 타입 사용 객체 (Typed Object) 클래스를 정의한다.

이 때 타입 사용 객체에서 타입 객체를 인스턴스로 받아오기 때문에 다른 타입 객체 인스턴스들은 서로 다른 타입 (여기서는 종족) 이 되는 것이다.

이러면 어떤 짓이 가능하느냐?

타입 간에 같은 기능을 가지는 부분은 타입 사용 객체 (Monster) 에 작성하고 타입끼리 서로 다른 데이터와 기능타입 객체 (Breed) 에 작성하면 되는 것이다!

즉, 상속 처리를 하드코딩하지 않고도 상속 받는 느낌의 기능을 구현할 수 있는 것이다.

주의사항

언어에서 기본적으로 지원하는 상속 기능을 피하고 우리만의 패턴으로 구현한 것이기 때문에 당연히 신경써야 하는 부분들이 있다.

  1. 타입 객체를 직접 관리해야 한다.

타입 객체를 필요로 하는 인스턴스가 존재하면 메모리에 타입 객체가 항상 존재해야 하고, 해당 타입 객체를 우리가 알맞게 지정해줘야 한다.

  1. 타입 별로 동작을 표현하기 더 어렵다.

오버라이드해서 클래스 별 동작을 구현하는 상속을 사용하지 않으니, 타입 별 동작을 구현하기가 좀 어렵다.

위의 코드에서도 당장 Delegate(Action)를 이용하여 구현했지 않은가.

이러면 해당 동작을 구현하기 위한 Action 메서드 역시 어딘가에 미리 구현해놓아야 한다.


팩토리 메서드 패턴과 섞어보기

위에서 구현한 타입 객체 패턴은 메모리 할당이 먼저 이루어지고 그 메모리에 객체를 할당하는 방식이기 때문에 초기화되지 않은 객체가 메모리에 올라가게 되는 것이다.

이는 객체 생성 과정을 제어할 수가 없는 것이다.

클래스의 생성자가 호출될 때 타입 객체가 알아서 새로운 인스턴스를 생성하게 만들 수는 없는걸까?

Breed에 다음과 같은 코드를 추가하자.

public class Breed
{
    public Monster newMonster()
    {
        return new Monster(this);   
    }
}

위의 newMonster팩토리 메서드 패턴의 생성자이다.

팩토리 메서드 패턴에 대해서는 다음에 자세히 소개할 수 있었으면 좋겠다.

이러면 이전 코드에서 몬스터를 다음과 같이 생성했던 것을

// 이전 방식
Monster monster = new Monster(someBreed);

다음과 같이 바꿀 수 있게 된다.

// 수정된 방식
Monster monster = someBreed.newMonster();

이제 BreedMonster의 초기화 제어권을 가져오기 때문에 Monster가 생성되기 전에 객체가 생성될 메모리 위치를 정의한다던지 등의 작업이 가능해진다.

따라서 타입 객체에서 메모리 할당을 제어하고 싶은 경우 다음과 같이 팩토리 메서드 패턴과 결합하고, 그렇지 않고 외부 코드에서 메모리 할당을 제어하고 싶은 경우 기존처럼 객체를 생성한 뒤 타입 객체를 넘겨주도록 하자.


타입 객체의 상속 구현하기

타입 객체 사이에도 일종의 계층 (Hierarchy) 을 가지도록 만들 수가 있다.

위의 예시대로라면 종족 타입이 상위 종족과 하위 종족으로 나뉠 수 있다는 소리이다.

다음과 같이 Breedparent 속성을 가지도록 정의해주자.

public class Breed
{
    private Breed _parent;
    public int Health { get; private set; }
    public Action AttackAction;
    
    Breed(Breed parent, int health, Action attack)
    {
        _parent = parent;
        Health = health;
        AttackAction = attack;
    }

    public Monster newMonster()
    {
        return new Monster(this);   
    }
}

상속받는 종족 객체가 parent로 들어가게 되는 것이다.

최상위 종족은 parentnull로 해주면 되는 것이다.

이제 parent가 null이 아닌, 즉 상속을 받는 타입 객체들은 어떤 속성을 상속받을 지, 또는 상속 받지 않을지 (오버라이드할지) 를 직접 결정해주면 된다.

다음은 상속받은 타입 객체의 health가 0이면 상속을 받고, 아니면 오버라이드를 하도록 작성한 코드이다.

public int Health
{
    get
    {
        // 오버라이딩
        if (_health != 0 || _parent == null)
            return _health;
        
        // 상속
        return _parent._health;
    }
}

뭔가 앞에서 배웠던 프로토타입 패턴이 생각나지 않는가?

이처럼 상속을 지원하도록 만든다면 중복 작업을 줄일 수 있다는 장점이 있지만, 속성값을 얻어오기 위해 부모 타입을 찾아가야 한다는 점에서 오버헤드가 발생한다.

심지어 다중 타입 상속까지 지원하도록 만들면?

대부분의 데이터 중복을 없앨 수 있는 대신 코드가 매우 복잡해진다는 단점이 따라온다.


어떻게 사용할 것인가?

타입 객체 패턴은 어찌보면 만능으로 보이지만서도 활용하기가 매우 어렵다.

조금이라도 더 복잡해지면 이해하기가 힘들어지고, 사용성이 떨어지기 때문이다.

다음은 타입 객체를 다루기 위해 고려할 사항들을 정리한 것이다.

고려 사항 1. 타입 객체를 숨길 것인가? 노출할 것인가?

다음처럼 타입 사용 객체에서 타입 객체를 반환해줄 수 있다.

public class Monster
{
    private Breed _breed;

    public Breed GetBreed { get; }
}

타입 객체를 노출하면 다음과 같은 장점이 생긴다.

  • 타입 사용 클래스 인스턴스를 통하지 않아도 외부에서 타입 객체에 접근이 가능하다.
  • 타입 객체가 공개 API의 일부가 된다.
  • 타입 객체 메서드를 전부 포워딩하지 않아도 좋다 (외부 공개 메서드를 따로 작업하지 않아도 된다) .

노출을 하지 않고 숨겼을 때, 즉 캡슐화를 했을 때는 다음과 같은 장점을 가진다.

  • 타입 객체 패턴의 복잡성이 다른 코드에 드러나지 않는다.
  • 타입 객체로부터 동작을 선택적으로 오버라이드 할 수 있다.

고려 사항 2. 타입을 바꿀 수 있는가?

바꿀 수 없게 만든다면 코드를 구현하고 이해하기가 더 쉬워진다.

이는 즉 디버깅하기가 쉬워진다는 의미이기도 하다.

타입을 바꿀 수 있게 만든다면 코드가 복잡해지는 대신 (타입 사용 객체와 타입 객체는 강하게 커플링되기 때문에 주의가 필요하다!) 객체 생성 횟수가 줄어든다.

몬스터가 죽으면 좀비 몬스터로 태어나는 기능을 구현한다고 했을 때, 타입 객체의 참조만 바꿔주는 방식으로 구현이 가능하기 때문에 메모리 낭비를 줄일 수 있다.


다른 패턴과의 차이점

앞서 언급한 프로토타입 패턴이 생각나는 이유는 이런 타입 객체 패턴은 프로토타입 패턴과 같은 문제에 대해 다른 식으로 접근하는 패턴이기 때문이다.

두 패턴 모두 여러 객체끼리 데이터와 동작을 공유하기 위한 패턴이기 때문이다.

게임 디자인 패턴 4. 프로토타입 (Prototype)
들어가며프로토타입을 이용한 오브젝트 생성 구현프로토타입을 이용한 데이터 모델링 들어가며 프로토타입이라는 디자인 패턴은 원형이 되는 인스턴스와 그것의 견본(프로토타입)을 만드는 방식의 패턴이다. 말이 좀 어려운데, 쉽게 설명하자면 객체를 생성하려 할 때 비슷한 객체가 있으면 그것을 복사하여 생성하는 패턴이다. 왜 굳이 복사를 하나요?라고 물어볼 수 있는데, ”생성에 드는 비용 > 복사에 드는 비용”일 경우 복사를 하는 것이 이득 아니겠는가? 프로토타입을 이용한 오브젝트 생성 구현 구현은 다음과 같이 한다. using UnityEngine; public class Monster : MonoBehaviour { public virtual Monster Clone() { return new Monster(); ..
https://seoksii.tistory.com/71

인스턴스가 여러 데이터를 공유하고, 메모리를 절약하도록 하기 위한 경량 패턴과도 비슷한 점이 매우 많다.

게임 디자인 패턴 2. 경량 패턴 (Flyweight Pattern)
들어가며경량 패턴의 구현?방법 1) 스크립터블 오브젝트 (Scriptable Object)방법 2) 정적 배칭 (Static Batching)경량 패턴을 언제 사용할지 모르겠어요 들어가며 경량 패턴.무엇이 가볍길래? 설명에 따르면 사용하는 메모리가 가볍다는 의미이다. 객체의 수가 너무 많아서 메모리의 양에 차지하는 공간이 많을 때, 객체를 가볍게 만들어서 최적화하는 패턴을 경량 패턴이라고 한다. 그래서 가볍게는 어떻게 만드나요?모든 개체가 똑같은 값을 가지고 있는 데이터를 모아서 개체마다 저장하는 것이 아닌 한 곳에다 저장한 후 모든 개체가 그 데이터를 참조하는 방법으로 만든다! 이러한 공통 데이터를 GoF에서는 고유 상태 (intrinsic state) 라고 부르고 그렇지 않는 개별 데이터는 외부 상태..
https://seoksii.tistory.com/69

상태 패턴과도 비슷한 점이 많다. 다른 객체에 자신을 정의하는 부분의 일부를 위임하기 때문이다.

타입 객체 패턴에서 타입 객체를 교체할 수 있다면, 타입 객체가 상태 패턴의 기능까지 같이 한다고 볼 수 있겠다.

게임 디자인 패턴 6. 상태 패턴 (State Pattern)
유한 상태 기계 (FSM, Finite State Machine)상태 패턴의 구현우리는 이미 상태 패턴을 알고 있다상태 패턴의 형태언제 사용하는가? 상태 패턴이다.오히려 고전적인 구현에 더 많이 쓰이는 듯하다.회로라던지 통신의 로우 레이어라던지. 상태 패턴도 요즘은 좀 남발되는 경우가 많은 것 같다.AI가 대두되고, 이것이 게임에 적용되기 시작하면서 더더욱 그런 것 같다. 유한 상태 기계 (FSM, Finite State Machine) 유한 상태 기계는 상태와 상태 간의 전환을 기반으로 동작하는 동작 기반 시스템이다. 이렇게만 이야기 하면 감이 잘 오지 않는데, 이런 상황을 구해야 한다고 해보자. 청기백기 게임인데, 아래-중간-위가 있다고 해보면 다음 그림의 느낌이 될 것이다. 이 때, 상태 패턴 없이..
https://seoksii.tistory.com/73


Uploaded by N2T