타입 객체 (Type Object)
13.1 의도
하나의 클래스로 만든 인스턴스가 서로 다른 객체형을 표현하게 하여, 새로운 ‘클래스들’을 유연하게 만들 수 있게 합니다.
13.2 동기
판타지 RPG에서 용맹한 영웅을 쓰러뜨리려고 돌아다니는 포악한 몬스터 무리를 구현한다고 해보겠습니다. 몬스터에는 체력, 공격, 그래픽 리소스, 사운드 같은 여러 속성이 있지만, 이 예제에서는 체력과 공격만 살펴봅니다.
모든 몬스터는 체력이 있습니다. 최대 체력으로 시작해서 피해를 입을 때마다 조금씩 줄어듭니다. 공격 문구(attack string)도 있습니다. 몬스터가 영웅을 공격하면 유저에게 이 문구를 보여줍니다. 구체적인 표시 방식은 여기에서 다루지 않습니다.
기획자는 ‘용’이나 ‘트롤’처럼 다양한 몬스터 종족(breed)을 만들고 싶어 합니다. 종족은 몬스터의 특징을 나타내며, 던전에는 같은 종족의 몬스터가 여러 마리 동시에 돌아다닐 수 있습니다.
종족은 최대 체력을 결정합니다. 용은 트롤보다 시작 체력이 높아 죽이기 훨씬 어렵습니다. 공격 문구도 종족이 결정합니다. 같은 종족은 공격하는 방식도 같습니다.
전형적인 객체지향 방식
이 기획을 코드로 옮겨보겠습니다. 용이나 트롤은 모두 몬스터의 일종이므로, 객체지향 방식에서는 Monster라는 상위 클래스를 만드는 것이 자연스럽습니다.
class Monster {
public:
virtual ~Monster() {}
virtual const char* getAttack() = 0;
protected:
Monster(int startingHealth) : health_(startingHealth) {}
private:
int health_; // 현재 체력
};
C++
복사
공개 메서드 getAttack()은 몬스터가 영웅을 공격할 때 보여줄 문구를 반환합니다. 하위 클래스는 이를 재정의하여 다른 공격 문구를 제공합니다.
생성자는 protected이며 최대 체력을 인수로 받습니다. 각 종족을 정의하는 하위 클래스는 공개 생성자를 만들고, 여기서 상위 생성자를 호출하면서 자기 종족의 최대 체력을 전달합니다.
원주 13-1: 이런 관계를 is-a 관계라고 합니다. 전통적인 객체지향 설계에서는 용이 몬스터이므로(dragon is-a monster) Dragon을 Monster의 하위 클래스로 모델링합니다. 뒤에서 보겠지만 이를 코드로 표현하는 방법이 상속뿐인 것은 아닙니다.
종족을 표현하는 하위 클래스는 다음과 같습니다.
class Dragon : public Monster {
public:
Dragon() : Monster(230) {}
virtual const char* getAttack() {
return "용이 불을 뿜습니다!";
}
};
class Troll : public Monster {
public:
Troll() : Monster(48) {}
virtual const char* getAttack() {
return "트롤이 당신을 곤봉으로 내리칩니다!";
}
};
C++
복사
원주 13-2: 어디든 느낌표를 붙이면 더 신납니다!
하위 클래스는 최대 체력을 전달하고 getAttack()을 재정의하여 종족에 맞는 문구를 반환합니다. 지금까지는 계획대로입니다. 머지않아 영웅이 돌아다니며 여러 몬스터를 처치할 수 있게 될 것입니다. 하지만 이런 방식으로 계속 만들다 보면 산성 슬라임부터 좀비 염소까지 Monster 하위 클래스가 엄청나게 늘어납니다.
그쯤 되면 작업이 이상하게 느려집니다. 몬스터를 수백 종 만들려면 프로그래머들이 하루 종일 몇 줄짜리 하위 클래스를 작성하고 컴파일해야 합니다. 기획자가 이미 만든 종족을 다듬기 시작하면 생산성은 더욱 떨어집니다. 다음 과정을 생각해 보세요.
1.
트롤의 최대 체력을 48에서 52로 바꿔 달라는 기획자의 이메일을 받습니다.
2.
Troll.h를 체크아웃하여 수정합니다.
3.
컴파일합니다.
4.
변경 사항을 체크인합니다.
5.
이메일에 답장합니다.
6.
1~5를 계속 반복합니다.
프로그래머는 자신이 ‘데이터 멍키’가 되었다며 좌절합니다. 기획자도 숫자 몇 개 바꾸는 데 하루가 걸려 답답합니다. 종족의 상태 값은 게임을 빌드하지 않고도 바꿀 수 있어야 합니다. 더 나아가 기획자가 프로그래머의 도움 없이 종족을 새로 만들고 값을 수정할 수 있어야 합니다.
원주 13-3 · 옮긴이: 지시받은 코드를 생각 없이 입력하는 프로그래머를 비하하여 ‘코드 멍키’라고 부릅니다. ‘데이터 멍키’도 생각 없이 데이터를 입력하는 사람을 가리킵니다.
클래스를 위한 클래스
문제의 개요는 간단합니다. 게임에 여러 종류의 몬스터가 있으므로 일부 속성을 여러 몬스터가 공유하게 하면 됩니다. 예를 들어 영웅을 공격할 때 같은 공격 문구를 보여줄 몬스터들을 같은 ‘종족’으로 정의하고, 종족이 같으면 문구도 같게 합니다.
앞에서는 이를 반사적으로 클래스 상속으로 구현했습니다. 용은 몬스터이고, 게임에 스폰된 용은 Dragon 클래스의 인스턴스입니다. 종족마다 추상 상위 클래스 Monster를 상속하는 클래스를 정의하고, 실제 몬스터는 그 종족 클래스의 인스턴스로 만듭니다.
그림 13-1. 종족마다 상속하면 하위 클래스가 너무 많아집니다
원주 13-4: 삼각형 화살표는 ‘~로부터 상속받는다’는 뜻입니다.
스폰된 몬스터 인스턴스의 타입은 모두 몬스터 클래스를 상속합니다. 종족 수가 늘수록 상속 구조도 커지고, 새 종족을 만들 때마다 코드를 추가하고 컴파일해야 합니다.
다른 방법은 몬스터마다 종족 정보를 두는 것입니다. 종족별로 상속하지 않고 Monster 클래스 하나와 Breed 클래스 하나만 만듭니다.
그림 13-2. Monster와 Breed 두 클래스로 무한한 종족을 표현합니다
원주 13-5: 마름모 화살표는 여기에서 ‘~를 참조한다’는 뜻으로 사용했습니다.
이제 상속 없이 클래스 두 개로 해결할 수 있습니다. 모든 몬스터는 Monster 인스턴스입니다. Breed에는 같은 종족이 공유하는 최대 체력과 공격 문구를 넣습니다.
두 객체를 연결하기 위해 모든 몬스터는 자기 종족 정보를 담은 Breed 객체를 참조합니다. 공격 문구가 필요하면 종족 객체의 메서드를 호출합니다. Breed는 본질적으로 몬스터의 ‘타입’을 정의합니다. 각각의 종족 객체가 개념적으로 다른 타입을 나타내므로 패턴 이름이 타입 객체입니다.
이 패턴은 코드를 수정하지 않고 새로운 타입을 정의할 수 있다는 장점이 있습니다. 클래스 상속으로 구현하던 타입 시스템 일부를 런타임에 정의할 수 있는 데이터로 옮긴 것입니다.
Breed 인스턴스를 새로 만들어 다른 값을 넣으면 새로운 종족이 됩니다. 설정 파일을 읽어 종족 객체를 만들면 데이터만으로 전혀 다른 몬스터를 정의할 수 있고, 기획자도 새 몬스터를 쉽게 만들 수 있습니다.
그림의 연결을 실제 게임에 대입하기: 트롤 세 마리는 각자 위치와 현재 체력을 가지지만, 같은 ‘트롤 종족’ 객체 하나를 참조할 수 있습니다. 트롤 A가 피해를 받아도 트롤 B의 체력은 줄지 않습니다. 공유하는 것은 종족의 최대 체력과 공격 설명이며, 현재 체력은 각 개체의 상태입니다.
13.3 패턴
타입 객체(type object) 클래스와 타입 사용 객체(typed object) 클래스를 정의합니다. 타입 객체의 각 인스턴스는 논리적으로 서로 다른 타입을 나타냅니다. 타입 사용 객체는 자기 타입을 나타내는 타입 객체를 참조합니다.
인스턴스마다 다른 데이터는 타입 사용 객체에 저장합니다. 개념적으로 같은 타입끼리 공유하는 데이터와 동작은 타입 객체에 저장합니다. 같은 타입 객체를 참조하는 객체들은 같은 타입인 것처럼 동작합니다. 상속을 하드코딩하지 않고도 비슷한 객체들이 상속한 것처럼 데이터와 동작을 공유할 수 있습니다.
13.4 언제 쓸 것인가?
다양한 ‘종류’를 정의해야 하지만 개발 언어의 타입 시스템이 충분히 유연하지 않아 코드로 표현하기 어려울 때 적합합니다. 특히 다음 중 하나라도 해당하면 고려할 수 있습니다.
•
나중에 어떤 타입이 필요할지 알 수 없습니다. 예를 들어 새로운 몬스터가 등장하는 DLC(downloadable content)를 제공해야 할 수도 있습니다.
•
컴파일이나 코드 변경 없이 타입을 추가하거나 수정하고 싶습니다.
13.5 주의사항
핵심은 표현력이 좋지만 뻣뻣한 코드 대신, 표현력은 다소 떨어져도 훨씬 유연한 데이터로 타입을 표현하는 것입니다. 유연성을 얻는 대신 잃는 것도 있습니다.
타입 객체를 직접 관리해야 합니다
C++ 같은 언어의 타입 시스템은 컴파일러가 클래스에 필요한 잡무를 처리해 줍니다. 클래스를 정의하는 데이터가 컴파일 과정에서 실행 파일의 정적 메모리 영역에 들어가 자동으로 동작합니다.
타입 객체 패턴에서는 몬스터뿐 아니라 타입 객체도 직접 관리해야 합니다. 이를 생성하고, 해당 타입을 필요로 하는 몬스터가 남아 있는 동안 메모리에 유지해야 합니다. 몬스터를 만들 때 올바른 종족 객체의 참조로 초기화하는 일도 우리의 책임입니다.
컴파일러의 한계를 일부 뛰어넘는 대가로 컴파일러가 하던 일을 직접 구현해야 합니다.
원주 13-6: C++ 가상 함수는 일반적으로 vtable로 구현합니다. 클래스마다 가상 함수 포인터를 저장한 테이블이 하나씩 있고, 인스턴스는 자기 클래스의 테이블을 가리킵니다. 가상 함수를 호출할 때 객체에서 vtable을 찾고, 그 안의 함수 포인터를 찾아 호출합니다. vtable은 종족 객체에, vtable 포인터는 몬스터의 종족 참조에 대응합니다. 이렇게 보면 C++ 클래스는 컴파일러가 C 언어 위에 타입 객체 패턴을 적용한 것이라고 볼 수 있습니다.
원주 13-7 · 옮긴이: 예를 들어 클래스 메서드는 실행 파일의 코드 세그먼트(code segment)에 들어갑니다.
타입별 동작을 표현하기가 더 어렵습니다
상속 방식에서는 메서드를 재정의하여 값을 계산하거나 다른 코드를 호출하는 등 원하는 일을 할 수 있습니다. 늑대인간처럼 달의 모양에 따라 공격 문구가 바뀌는 몬스터도 만들 수 있습니다.
타입 객체 방식에서는 메서드를 재정의하는 대신 종족 객체의 변수에 공격 문구를 저장합니다. 타입별 데이터는 정의하기 쉽지만 타입별 동작은 어렵습니다. 종족마다 다른 AI 알고리즘을 적용하려면 어떻게 해야 할까요? 이 패턴만으로는 구현이 까다로워집니다.
이를 우회하는 방법은 몇 가지 있습니다. 가장 간단하게는 동작 코드를 여러 개 미리 정의하고 타입 데이터에서 하나를 선택합니다. 몬스터의 AI가 ‘가만히 서 있기’, ‘영웅을 쫓아가기’, ‘무서워서 벌벌 떨기’ 중 하나라고 해보겠습니다. 모든 몬스터가 힘센 용은 아니니까요. 각 동작의 함수를 만들고 타입 객체에 적절한 함수 포인터를 저장하면 AI와 타입을 연결할 수 있습니다.
더 나아가 동작 자체를 데이터로 정의할 수도 있습니다. 바이트코드(11장)나 GoF의 인터프리터 패턴을 이용하면 동작을 표현하는 객체를 만들 수 있습니다. 파일을 읽어 이런 자료구조를 구성하면 동작 정의를 코드에서 데이터로 완전히 옮길 수 있습니다.
원주 13-8: 함수 포인터를 저장하는 방식도 익숙하지 않나요? 이번에는 우리 타입 객체에서 vtable을 구현하고 있습니다.
원주 13-9: 게임은 점차 데이터 주도 방식으로 변해 왔습니다. 하드웨어가 좋아지면서 한계도 ‘하드웨어를 얼마나 최적화하는가’에서 ‘콘텐츠를 얼마나 많이 만드는가’로 옮겨갑니다. 원문은 64K 카트리지 시절에는 게임을 좁은 공간에 넣는 일이 문제였고, 양면 DVD 시대에는 그 공간을 게임플레이로 채우는 일이 문제라고 설명합니다. 스크립트 같은 고수준 방식은 실행 성능을 일부 희생해도 생산성을 크게 높일 수 있습니다. 하드웨어는 꾸준히 좋아지지만 인간의 두뇌는 그렇지 않으므로 이런 절충이 늘어난다는 이야기입니다.
13.6 예제 코드
앞서 살펴본 시스템의 기본부터 구현하겠습니다. 먼저 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_;
};
C++
복사
최대 체력 health_와 공격 문구 attack_, 두 필드만 있습니다. 몬스터가 이를 사용하는 방식은 다음과 같습니다.
class Monster {
public:
Monster(Breed& breed)
: health_(breed.getHealth()),
breed_(breed) {
}
const char* getAttack() { return breed_.getAttack(); }
private:
int health_; // 현재 체력
Breed& breed_;
};
C++
복사
생성자가 Breed를 참조로 받으므로 상속 없이 종족을 정의합니다. 최대 체력은 생성 시 breed에서 얻고, 공격 문구는 breed_에 요청을 전달하여 얻습니다.
이것이 타입 객체 패턴의 핵심입니다. 나머지 내용은 덤이라고 생각하고 읽어보세요.
공유와 복사의 차이: 공격 문구는 매번 Breed에서 읽지만, 현재 체력은 생성자에서 한 번 복사합니다. 종족의 최대 체력을 바꾸었다고 살아 있는 몬스터의 현재 체력이 저절로 바뀌지는 않습니다. 실시간 밸런스 수정을 지원하려면 기존 개체를 그대로 둘지, 비율을 유지할지, 새 최대치로 제한할지 별도로 정해야 합니다.
생성 함수로 타입 객체를 더 타입답게 만들기
지금까지는 몬스터를 생성하면서 종족 객체를 직접 전달했습니다. 메모리를 먼저 할당하고 그 공간에 클래스를 지정하는 것과 비슷합니다. 대부분의 객체지향 언어에서는 이렇게 하지 않고 클래스의 생성 함수를 호출하여 클래스가 새 인스턴스를 만들어 주게 합니다.
타입 객체에도 이 패턴을 적용할 수 있습니다.
class Breed {
public:
Monster* newMonster() {
return new Monster(*this);
}
// 나머지는 동일합니다.
};
C++
복사
Monster는 다음과 같이 바뀝니다.
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_;
};
C++
복사
가장 큰 변화는 Breed::newMonster()입니다. 이것이 팩토리 메서드 패턴의 ‘생성자’입니다.
원주 13-10 · 옮긴이: 여기의 ‘생성자 함수’는 C++ 생성자보다는 팩토리 함수에 가깝습니다.
원주 13-11: GoF의 고전적인 팩토리 메서드 패턴입니다. 일부 언어에서는 모든 객체 생성에 이 방식이 적용됩니다. 루비, 스몰토크, 오브젝티브C처럼 클래스도 객체인 언어에서는 클래스 객체의 메서드를 호출하여 새 인스턴스를 만듭니다.
이전에는 이렇게 생성했습니다.
Monster* monster = new Monster(someBreed);
C++
복사
이제는 이렇게 생성합니다.
Monster* monster = someBreed.newMonster();
C++
복사
원주 13-12: C++의 friend도 사용했습니다. 몬스터 생성자는 private이라 외부에서 직접 호출할 수 없지만, 친구 클래스 Breed는 호출할 수 있습니다. 따라서 newMonster()를 통해 몬스터를 만들도록 제한합니다.
무엇이 좋아졌을까요? 객체 생성은 메모리 할당과 초기화라는 두 단계로 이루어집니다. 생성자에서 필요한 초기화는 모두 할 수 있습니다. 예제에서는 종족을 받는 것이 전부지만 실제 게임에서는 그래픽 로딩이나 AI 설정 등 여러 작업이 필요할 수 있습니다.
그런 초기화는 메모리를 할당한 다음에 실행됩니다. 먼저 아직 초기화되지 않은 객체가 들어갈 메모리가 있어야 합니다. 게임에서는 생성 과정을 제어하고 싶을 때가 많으며, 커스텀 할당자나 객체 풀(19장)을 이용해 어디에 객체를 만들지 결정하기도 합니다.
종족 클래스에 생성 함수를 두면 이 로직을 넣을 자리가 생깁니다. new 대신 newMonster()를 호출하면 몬스터 생성자로 초기화 제어권을 넘기기 전에 메모리 풀이나 커스텀 힙에서 공간을 얻을 수 있습니다. 유일한 생성 경로인 종족 클래스에 이를 두면 모든 몬스터가 정해진 메모리 관리 규칙을 따르도록 강제할 수 있습니다.
상속으로 데이터 공유하기
여기까지로도 실무에서 쓸 만한 시스템을 만들 수 있습니다. 하지만 개발을 진행하면 종족이 수백 개로 늘고 속성도 많아질 것입니다. 30개가 넘는 트롤 종족을 조금씩 강하게 바꾸려면 많은 데이터를 반복해서 고쳐야 합니다.
종족을 통해 여러 몬스터가 속성을 공유했듯이, 여러 종족도 값을 공유하면 좋겠습니다. 처음의 객체지향 방식처럼 상속을 이용할 수 있습니다. 다만 언어의 상속 기능 대신 타입 객체끼리 상속하는 시스템을 직접 구현합니다.
간단하게 단일 상속만 지원하겠습니다. 클래스에 상위(parent) 클래스가 있듯 종족에도 상위 종족을 둘 수 있게 합니다.
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_;
int health_; // 최대 체력
const char* attack_;
};
C++
복사
생성할 때 상속받을 종족을 전달합니다. 최상위 종족처럼 부모가 없다면 NULL을 전달합니다.
하위 종족은 어떤 값을 상속하고 어떤 값을 재정의할지 제어할 수 있어야 합니다. 예제에서는 체력이 0이 아니거나 공격 문구가 NULL이 아니면 자기 값을 사용하고, 그렇지 않으면 부모의 값을 사용합니다.
구현은 두 가지가 가능합니다. 먼저 요청받을 때마다 동적으로 위임하는 방식입니다.
int Breed::getHealth() {
// 오버라이딩
if (health_ != 0 || parent_ == NULL) {
return health_;
}
// 상속
return parent_->getHealth();
}
const char* Breed::getAttack() {
// 오버라이딩
if (attack_ != NULL || parent_ == NULL) {
return attack_;
}
// 상속
return parent_->getAttack();
}
C++
복사
런타임에 특정 속성의 재정의나 상속 여부가 바뀔 때 좋습니다. 대신 부모 포인터를 유지하므로 메모리가 더 필요하고, 값을 구할 때마다 부모들을 연달아 확인하므로 느려질 수 있습니다.
종족 속성이 바뀌지 않는다면 생성할 때 상속을 적용할 수 있습니다. 이를 카피다운(copy-down) 위임이라고 합니다. 생성 시 상속할 값을 하위 타입에 복사하기 때문입니다.
Breed(Breed* parent, int health, const char* attack)
: health_(health),
attack_(attack) {
// 오버라이드하지 않는 속성만 상속받습니다.
if (parent != NULL) {
if (health == 0)
health_ = parent->getHealth();
if (attack == NULL)
attack_ = parent->getAttack();
}
}
C++
복사
생성자에서 필요한 부모 속성을 전부 복사했으므로 이후에는 부모를 가리키는 포인터를 유지할 필요가 없습니다. 값을 반환할 때는 자기 필드를 그대로 읽으면 됩니다.
int getHealth() { return health_; }
const char* getAttack() { return attack_; }
C++
복사
훨씬 깔끔하고 빠릅니다. 종족을 JSON으로 정의한다고 해보겠습니다.
{
"트롤": {
"체력": 25,
"공격문구": "트롤이 당신을 때립니다!"
},
"트롤 궁수": {
"부모": "트롤",
"체력": 0,
"공격문구": "트롤 궁수가 활을 쏩니다!"
},
"트롤 마법사": {
"부모": "트롤",
"체력": 0,
"공격문구": "트롤 마법사가 마법 공격을 합니다!"
}
}
JSON
복사
이 데이터를 읽어 새로운 종족 인스턴스를 만듭니다. "부모": "트롤"에서 볼 수 있듯 트롤 궁수와 트롤 마법사는 트롤 종족을 상속합니다.
두 하위 종족의 체력은 0이므로 부모에게서 체력을 얻습니다. 따라서 기획자가 트롤의 체력만 바꾸어도 세 종족의 체력을 모두 바꿀 수 있습니다. 종족과 속성 수가 많아질수록 상속으로 아끼는 시간이 커집니다. 얼마 안 되는 코드로 기획자가 자유롭게 제어하는 열린 시스템을 만들었습니다. 기획자는 시간을 잘 활용하고, 프로그래머는 다른 기능 구현에 집중할 수 있습니다.
원주 13-13 · 옮긴이: 프로토타입 패턴(5장)에서도 비슷한 예제를 다룹니다.
데이터 갱신 규칙을 명확히 하기: 카피다운은 복사한 시점의 값을 보관합니다. JSON의 부모 체력을 고친 뒤 이미 만들어진 하위 종족에도 적용하려면 데이터를 다시 읽고 종족을 재구성하거나 전파 로직을 실행해야 합니다. 동적 위임은 다음 조회부터 부모의 새 값을 읽습니다. 또한 이 예제는 0을 ‘상속’ 표시로 사용하므로, 체력이 정말 0인 종족을 직접 표현하려면 값의 존재 여부를 별도 필드로 구분하는 편이 좋습니다.
13.7 디자인 결정
타입 객체는 프로그래밍 언어를 새로 만드는 것처럼 타입 시스템을 자유롭게 설계할 수 있어 선택의 폭이 넓고 재미있는 시도도 가능합니다.
하지만 현실에서는 몇 가지 이유로 기능을 제한하게 됩니다. 시스템이 복잡할수록 개발 기간이 늘고 유지보수가 어려워집니다. 개발자는 프로그래머여도 사용자는 비프로그래머인 경우가 많으므로 쉽게 이해할 수 있어야 합니다. 간단할수록 사용성도 좋습니다. 여기에서는 검증된 방식만 다루고 나머지는 학계와 탐구자들에게 맡기겠습니다.
타입 객체를 숨길까, 노출할까?
앞의 몬스터는 종족을 참조하지만 이를 외부에 공개하지 않았습니다. 종족 객체는 구현 상세이므로 외부에서는 몬스터에 별도의 타입이 있는지 알 필요가 없습니다.
물론 다음과 같이 종족 객체를 반환하도록 바꿀 수 있습니다.
class Monster {
public:
Breed& getBreed() { return breed_; }
// 나머지 코드...
};
C++
복사
모든 몬스터가 종족 객체를 가진다는 사실이 공개 API에 포함되므로 설계가 달라집니다. 두 방식에는 각각 장단점이 있습니다.
원주 13-14: 이 책의 예제는 함수가 NULL을 반환하지 않는다는 사실을 알리기 위해 포인터 대신 참조를 반환하는 규약을 사용합니다.
타입 객체를 캡슐화한다면:
•
패턴의 복잡성이 다른 코드에 드러나지 않습니다. 구현 상세는 타입 사용 객체 안에서만 고민하면 됩니다.
•
타입 사용 객체가 동작을 선택적으로 재정의할 수 있습니다. 거의 죽어가는 몬스터에게 다른 공격 문구를 주고 싶다면, 외부가 항상 몬스터를 통해 문구를 읽으므로 다음처럼 처리할 수 있습니다.
const char* Monster::getAttack() {
if (health_ < LOW_HEALTH) {
return "몬스터가 힘없이 팔을 휘두릅니다.";
}
return breed_.getAttack();
}
C++
복사
외부가 종족의 getAttack()을 직접 호출한다면 이런 코드를 넣을 자리가 없습니다.
•
공개할 메서드마다 전달 메서드를 만들어야 합니다. 타입 객체에 메서드가 많을수록 타입 사용 객체가 외부에 보여줄 모든 메서드를 포워딩해야 하므로 번거롭습니다.
타입 객체를 노출한다면:
•
타입 사용 객체 없이도 타입 객체에 접근할 수 있습니다. 앞의 생성 함수처럼 몬스터가 아직 없어도 Breed의 메서드를 호출하여 몬스터를 만들 수 있습니다.
•
타입 객체가 공개 API의 일부가 됩니다. 보통 인터페이스를 적게 노출할수록 복잡성은 줄고 유지보수는 쉬워집니다. 타입 객체를 공개하면 그 객체가 제공하는 모든 것이 API에 포함됩니다.
타입 사용 객체를 어떻게 만들까?
이 패턴의 객체는 타입 객체와 타입 사용 객체가 짝을 이룹니다. 둘을 어떻게 생성하고 연결할까요?
객체를 생성한 뒤 타입 객체를 전달하는 경우: 외부 코드가 두 객체를 생성하므로 메모리 할당을 제어합니다. 커스텀 할당자나 스택 등 여러 위치에 객체를 둘 수 있습니다.
타입 객체의 생성 함수를 호출하는 경우: 메모리 할당의 제어권을 타입 객체에 둡니다. 반대로 외부에 생성 위치 선택권을 주고 싶지 않을 때 유용합니다. 팩토리 메서드를 통하게 하면 모든 객체를 지정한 객체 풀(19장)이나 특정 할당자에서만 생성하도록 제한할 수 있습니다.
타입을 바꿀 수 있게 할까?
지금까지는 객체 생성 시 정한 타입이 소멸할 때까지 바뀌지 않는다고 가정했습니다. 반드시 그래야 하는 것은 아닙니다. 필요하면 타입을 바꾸게 만들 수도 있습니다.
몬스터가 죽은 뒤 좀비로 부활해야 한다고 해보겠습니다. 새로운 좀비 종족의 몬스터를 생성할 수도 있지만, 기존 몬스터의 종족을 좀비로 바꿀 수도 있습니다.
타입을 바꿀 수 없다면:
•
구현하고 이해하기가 쉽습니다. 개념적으로 타입은 변하지 않는다고 생각하기 쉬운데, 그 가정을 코드로 고정합니다.
•
디버깅이 쉽습니다. 몬스터의 상태가 이상해지는 버그를 조사할 때 종족은 변하지 않는다고 가정하면 일이 수월합니다.
타입을 바꿀 수 있다면:
•
객체 생성 횟수가 줄어듭니다. 변경할 수 없다면 새 좀비를 만들고, 기존 객체에서 유지할 속성을 복사한 뒤, 기존 몬스터를 삭제해야 합니다. 타입 변경이 가능하면 타입 객체 포인터만 바꾸면 됩니다.
•
기존 가정을 깨뜨리지 않도록 주의해야 합니다. 타입 객체와 타입 사용 객체는 강하게 결합합니다. 예를 들어 현재 체력은 해당 종족의 최대 체력보다 크지 않아야 할 수 있습니다. 종족을 바꾸면 기존 상태를 새 타입의 요구에 맞추어야 하며, 이를 위한 검증 코드가 필요할 수도 있습니다.
예제에서 타입 변경을 구현하려면: 위 Monster는 Breed&를 사용합니다. C++ 참조는 다른 객체로 다시 연결할 수 없으므로, 마지막 선택을 구현하려면 Breed*나 변경 가능한 타입 ID 같은 표현으로 설계를 바꿔야 합니다. 포인터를 바꾸는 것과 함께 현재 체력, 애니메이션, 충돌 크기 등의 일관성도 점검합니다.
상속을 어떻게 지원할까?
상속 없음:
•
단순합니다. 단순한 것이 가장 좋을 때도 많습니다. 타입끼리 공유할 데이터가 적다면 코드를 복잡하게 할 필요가 없습니다.
•
중복 작업이 생길 수 있습니다. 저자는 콘텐츠 저작 도구에 상속이 들어가는 것을 싫어하는 기획자를 보지 못했다고 말합니다. 엘프가 50종일 때 최대 체력을 바꾸려고 같은 값을 50군데 고치는 일은 고역입니다.
단일 상속:
•
그나마 단순합니다. 구현도 쉽지만 이해하기 쉽다는 점이 더 중요합니다. 기술에 익숙하지 않은 사용자는 손댈 부분이 적을수록 좋습니다. 많은 언어가 단일 상속을 선택하는 것도 상속의 장점과 코드의 단순함 사이에서 균형을 잡기 때문입니다.
•
속성을 얻는 시간이 늘어납니다. 실제 값을 정의한 타입을 찾을 때까지 상속 계층을 거슬러 올라가야 합니다. 성능에 민감한 코드에서는 이런 런타임 비용이 바람직하지 않습니다.
조회 비용과 연결해서 읽기: 이 비용은 앞의 동적 위임 방식에 해당합니다. 카피다운을 쓰면 생성 시 부모를 확인하고 이후에는 자기 필드만 읽으므로, 상속 계층 탐색 비용을 로딩 시점으로 옮길 수 있습니다.
다중 상속:
•
대부분의 데이터 중복을 없앨 수 있습니다. 사용자가 상속 구조를 잘 설계하면 복사와 붙여넣기 없이 값을 조정하고 중복을 거의 제거할 수 있습니다.
•
복잡합니다. 이런 장점은 실무보다는 이론에서 얻기 쉽습니다. 다중 상속은 이해하기 어렵습니다.
예를 들어 좀비 용(zombie dragon)이 좀비와 용을 모두 상속한다면 어떤 속성은 좀비에게서, 어떤 속성은 용에게서 받아야 할까요? 사용자는 상속 그래프의 흐름을 이해하고 구조를 잘 설계할 수 있는 선견지명도 필요합니다.
원문은 많은 C++ 코딩 표준에서 다중 상속을 금지하고, 자바와 C#에는 클래스 다중 상속이 없다는 점을 듭니다. 제대로 다루기 어렵다면 아예 사용하지 못하게 하는 편이 낫다는 아쉬운 사실을 보여준다는 이야기입니다. 고려할 가치는 있지만 게임의 타입 객체에 다중 상속이 필요한 경우는 드물 것입니다. 언제나 그렇듯 단순한 편이 낫습니다.
원주 13-15 · 옮긴이: 원문에서 제시한 구글 코딩 규약 링크는 인터페이스에 한해서 다중 상속을 허용한다고 설명하며 다른 규약도 비슷하다고 덧붙입니다. 이는 책에서 인용한 당시 규약에 관한 설명입니다.
13.8 관련 자료
•
높은 수준에서 보면 이 패턴은 여러 객체가 데이터와 동작을 공유하는 문제를 해결합니다. 프로토타입(5장)은 같은 문제에 다른 방식으로 접근합니다.
•
경량 패턴(3장)과도 매우 비슷합니다. 둘 다 여러 인스턴스가 데이터를 공유하게 합니다. 경량 패턴은 메모리 절약이 목표이며, 공유 데이터가 객체의 개념적 타입을 나타낼 수도 있고 아닐 수도 있습니다. 타입 객체는 조직화와 유연성에 더 초점을 둡니다.
•
상태 패턴(7장)과도 공통점이 많습니다. 둘 다 자신을 정의하는 일부를 다른 객체에 위임합니다. 타입 객체는 넓은 의미에서 해당 객체를 나타내는 불변 데이터를 주로 위임하고, 상태 패턴은 현재 상태를 나타내는 임시 데이터를 주로 위임합니다. 타입 객체를 교체할 수 있다면 상태 패턴의 역할도 함께 한다고 볼 수 있습니다.






