타입 객체 (Type Object)

클래스 하나를 인스턴스별로 다른 객체형으로 표현할 수 있게 만들어, 새로운 '클래스들'을 유연하게 만들 수 있게 한다.

 

 

동기

몬스터를 여러 종류 구현해야 한다고 하자.

전형적인 OOP 방식으로 구현하게 된다면 is-a 관계에 따라 Monster라는 상위 클래스를 구현하고 공통 인터페이스는 순수 가상 함수로, 공통 속성은 멤버 변수로써 갖는다. 하위 클래스는 이를 상속받아서 인터페이스를 구현한다. 매우 일반적인 구현이다.

 

문제는 몬스터의 종류만큼 하위 클래스의 개수도 끊임없이 늘어나고 유지보수도 매우 번거로워진다는 점이다.

 

접근 방식을 바꿔서 몬스터마다 종족에 대한 정보를 두어서 상속대신 종족 정보에 접근할 수 있는 Breed 클래스 하나를 새로 만들고 참조하도록 한다. 이러면 상속구조 없이 Monster, Breed 2개의 클래스만으로 해결할 수 있다.

몬스터와 종족을 결합시키기 위해서 모든 Monster 인스턴스는 종족 정보를 가지고 있는 Breed 객체를 참조하여 접근한다.

Breed 클래스는 본질적으로 몬스터의 타입을 정의하고 각각의 종족 객체는 개념적으로 다른 타입을 의미한다.

 

타입 객체 패턴은 코드의 수정 없이도 새로운 타입을 정의할 수 있는 것이 장점이다.

상속으로 만들어지던 타입 시스템의 일부를 런타임에 정의할 수 있는 데이터로 옮겨놓은 셈이다.

이제는 외부 파일에서 읽은 데이터로 종족 객체를 생성하고나면 데이터만으로 서로 다른 몬스터를 정의할 수 있게 된다.

 

 

패턴

타입 객체 클래스와 타입 사용 객체 클래스를 정의한다.

모든 타입 객체 인스턴스는 논리적으로 다른 타입을 의미하고, 타입 사용 객체는 자신의 타입을 나타내는 타입 객체를 참조한다.

예제를 기준으로 보면 Monster가 타입 사용 객체, Breed가 타입 객체이다.

 

인스턴스별로 다른 데이터는 타입 사용 객체 인스턴스에 저장하고, 개념적으로 같은 타입끼리 공유하는 데이터나 동작은 타입 객체에 저장한다.

 

상속 관계가 아님에도 불구하고 마치 상속받는것처럼 비슷한 객체끼리 데이터나 동작을 공유할 수 있다.

 

 

언제 쓸 것인가?

◾ 나중에 어떤 타입이 필요할지 알 수 없다.

◾ 컴파일이나 코드 변경 없이 새로운 타입을 추가하거나 타입을 변경하고 싶다.

 

이 중 하나라도 해당하면 타입 객체 패턴을 사용하기에 적합하다.

 

주의사항

타입 객체 패턴의 핵심은 표현력이 좋더라도 유연하지 않은 코드 대신 표현력이 조금 떨어지더라도 훨씬 유연한 데이터로 타입을 표현하는데에 있다.

유연성을 얻는 대신 코드가 아닌 데이터로 표현하면서 잃는 것도 생긴다.

 

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

타입 객체를 생성하고 필요로 하는 몬스터가 있는 한, 메모리에 계속 유지시켜야 한다. 몬스터 인스턴스를 생성할 때, 알맞는 종족 객체 레퍼런스로 초기화 하는것도 직접 해야한다.

 

◾ 타입별로 동작을 표현하기가 더 어렵다

상속의 경우 오버라이딩을 통해 원하는 대로 구현이 가능하다. 하지만 타입 객체 패턴은 종족 객체 변수에 문구를 저장하는 식으로 표현한다.

타입 종속적인 데이터를 정의하는 것은 쉽지만 동작을 정의하기는 어렵다.

이를 극복하기 위해서는 미리 동작 코드를 여러 개 정의해두고 함수 포인터로 가지면 된다.

 

좀 더 나아가서 바이트코드 패턴을 이용하면 동작을 표현하는 객체까지도 만들 수 있다.

 

 

예제

class Breed {
public:
    Breed(int health, const char* attack) : health_(health), attack_(attack) {}
    int getHealth() { return health_; }
    const char* getAttack() { return attack_; }
    
private:
    int health_;
    const char* attack_;
};

class Monster {
public:
    Monster(Breed& breed) : health_(breed.getHealth()), breed_(breed) {}
    const char* getAttack() { return breed_.getAttack(); }
    
private:
    int health_;
    Breed& breed_;
};

기본적인 타입 객체의 구현 방법이다. 상속 없이 몬스터를 정의한다.

그런데 이런식으로 몬스터 객체를 만드는건 OOP답지 않다. 대신, 팩토리 메소드와 유사한 생성자 함수를 통해 클래스가 알아서 인스턴스를 생성하도록 한다.

 

class Breed {
public:
    Monster* newMonster() { return new Monster(*this); }
    // .. 이하 동일
};

class Monster {
    friend class Breed;
    
public:
    const char* getAttack() { return breed_.getAttack(); }
    
private:
    Monster(Breed& breed) : health_(breed.getHealth()), breed_(breed) {}
    int health_;
    Breed& breed_;
};

Monster의 생성자를 private으로 만든 대신 Breed 클래스를 friend로 지정하여 객체 생성을 위임한다.

 

Monster* monster = new Monster(someBreed); // 기존 방법
Monster* monster = someBreed.newMonster(); // 생성자 함수 이용

겉으로 보기에는 별 차이가 없어보이지만 Monster의 초기화 제어권을 Breed 클래스가 가지고 있기 때문에 Monster 객체를 생성하기 전에 필요한 리소스들을 미리 가져오거나 메모리 풀, 커스텀 힙 등에서 메모리를 가져오는 작업들을 수행할 수 있다.

Breed의 생성자 함수를 통해서만 몬스터 객체를 생성할 수 있기 때문에 모든 몬스터가 정해놓은 메모리 관리 루틴을 따라서 생성되도록 강제할 수 있게 된다.

 

 

상속을 통해서도 데이터를 공유할 수 있는데, 일반적인 상속관계가 아니라 타입 객체끼리 상속할 수 있는 시스템을 직접 구현해서 사용한다.

 

class Breed {
public:
    Breed(Breed* parent, int health, const char* attack)
        : parent_(parent), health_(health), attack_(attack) {}
    int getHealth();
    const char* getAttack();
    
private:
    Breed* parent_;
    inth health_;
    const char* attack_;
};

int Breed::getHealth() {
    // 오버라이딩
    if (health_ != 0 || parent_ == nullptr) return health_;
    
    return parent_->getHealth();
}

const char* Breed::getAttack() {
    // 오버라이딩
    if (attack_ != nullptr || parent_ == nullptr) return attack_;
    
    return parent_->getAttack();
}

상속 문법을 사용하지 않고 상속 구조를 만든다. 대신 속성 값을 반환할때마다 상위 객체들을 재귀적으로 확인하는 과정이 있기 때문에 더 느리다는 단점이 있다.

 

만약 종족의 속성 값이 바뀌지 않는다고 하면 생성 시점에 바로 상속을 적용시켜서 간소화 시킬 수 있다. (카피다운 위임)

 

class Breed {
public:
    Breed(Breed* parent, int health, const char* attack)
        : health_(health), attack_(attack) {
        if (parent != nullptr) {
            if (health == 0) health_ = parent->getHealth();
            if (attack == nullptr) attack_ = parent->getAttack();
        }
    }

    int getHealth() { return health_; }
    int getAttack() { return attack_; }
    // ...
};

생성자에서 상위 속성을 모두 복사했기 때문에 더이상 상위 객체의 포인터(parent_)를 들고있지 않아도 된다.

 

 

디자인 결정

타입 객체의 캡슐화 여부와 생성 방법을 고려해볼 필요가 있다.

캡슐화의 여부는 각자 장단점이 존재하고 만약 캡슐화를 시킨다면 타입 객체 패턴의 복잡성이 다른 코드에는 드러나지 않게 되는 장점이 있다. 또한 같은 타입 객체로부터 동작을 선택적으로 오버라이드 할 수 있어서 코드의 추가가 어렵지 않다.

다만 타입 객체 메서드를 전부 일일이 포워딩 해주어야 하는 번거로운 작업이 발생한다.

 

타입 객체를 노출시키기로 했다면 타입 사용 클래스 인스턴스를 통하지 않고도 외부에서 타입 객체에 접근할 수 있게 되므로 타입 객체의 메서드를 통해서만 새로운 몬스터를 생성시키게 제한시킬 수 있다.

다만 타입 객체가 공개 API의 일부가 되어 포함되기 때문에 복잡성과 유지보수면에서 디메리트가 생긴다.

 

 

타입 객체 패턴에서 객체는 타입 객체와 타입 사용 객체 쌍으로 존재해야만 한다. 그럼 이 둘을 어떻게 생성하고 결합시키는게 좋을까?

 

◾ 객체를 생성한 뒤에 타입 객체를 넘겨주는 경우

두 객체 모두 외부에서 생성하기 때문에 외부 코드에서 메모리 할당을 제어할 수 있다.

 

◾ 타입 객체의 생성자 함수를 호출하는 경우

특정 객체 풀이나 특정 메모리 할당자에서만 생성하도록 제한하고 싶을 때 타입 객체에서 메모리 할당을 제어한다.

 

필요한 상황에 따라 선택하면 될듯하다.

 

타입의 변경과 상속에 관해서도 짚고 갈 사항이 있다.

한번 결정된 타입은 불변한다고 가정했지만 필요하다면 타입을 바꾸는 방법도 존재한다.

타입을 바꿀 수 없도록 설계한다면 코드를 구현하고 이해하는게 더 쉽고 디버깅 하기도 더 쉽다. 하지만 타입을 변경할 수 있도록 한다면 객체 생성 횟수가 줄어들기 때문에 자원의 낭비를 줄일 수 있다는 이점이 있지만 기존에 설정해둔 가정을 깨지 않도록 주의해야한다.

 

상속의 경우는 상속을 하지 않는 경우와 단일 상속, 다중 상속 총 3가지로 나눌 수 있다.

 

◾ 상속 없음

상속을 사용하지 않는다면 매우 단순해지는 대신 중복 작업을 해야 할수도 있다.

 

◾ 단일 상속

단일 상속은 그나마 단순한 편이지만 실제 값이 정의된 타입을 찾을 때까지 상속 구조를 타고 올라가야 하기 때문에 런타임의 자원이 낭비된다.

 

◾ 다중 상속

거의 모든 데이터 중복을 피할 수 있지만 구조가 복잡해진다.

애초에 타입 객체가 아니더라도 일반적인 경우에도 다중 상속을 지양(사실상 금지)하는 만큼 실무보다는 이론에 가까운 내용으로 이해하고 사용 역시 지양하는 편이 좋다.

 

 

타입 객체 패턴은 여러 패턴과 유사한 점들이 있다.

프로토타입 패턴은 같은 문제를 다른 방식으로 접근하고 경량 패턴은 여러 인스턴스가 같은 객체를 공유한다는 점에서 비슷하다.

또한 클래스 자신을 정의하는데에 있어서 일부 내용을 다른 클래스로 위임한다는 면에서는 상태 패턴과 유사점이 있다.

+ Recent posts