요즘 흔히 얘끼하는 프로토타입은 디자인패턴의 그것과는 다르다. 이번에는 프로토타입 패턴도 다루지만 더 흥미로운 주제인 ‘프로토 타입’용어와 개념이 어떻게 유래되었는지 알아볼것이다.
프토토타입 디자인 패턴
영웅을 저녁 식사 삼으려는 이 음침 녀서들은 게임마다 등장하는데 몬스터 종류마다 따로 존재한다.
몬스터(Ghost, Demon, Sorcerer)같은 클래슬 만들어보자
class Monster
{
// Stuff...
};
class Ghost : public Monster {};
class Demon : public Monster {};
class Sorcerer : public Monster {};
C++
복사
한 가지 스포너는 한 가지 몬스터 인스터스만 만든다. 게임에 나오는 모든 몬스터를 지원하기 위해 일단 마구잡이로 몬스터 클래스마다 스포너클래스를 만든다고 치자. 이렇게하면 스포너클래스가 상속구조인 몬스터를래스를 따라서 비슷한 형태로 만들어지게 된다.
class Spawner
{
public:
virtual ~Spawner() {}
virtual Monster* spawnMonster() = 0;
};
class GhostSpawner : public Spawner
{
public:
virtual Monster* spawnMonster()
{
return new Ghost();
}
};
class DemonSpawner : public Spawner
{
public:
virtual Monster* spawnMonster()
{
return new Demon();
}
};
// You get the idea...
C++
복사
코드를 많이 작성할수록 돈을 더 많는다면 위방식이 개발자한테는 더 좋은 방식일 것이다. 클래스도 많아지지… 행사코드도 길어지지… 반복코드도 많아지지… 중복도 많지…
이런걸 프로토 타입으로 해결할 수 있다. 핵심은 어떤 객체가 자기와 비슷한 객체를 스폰할수 있다는 점이다. 유령 객체 하나로는 다른 유령객체를 여럿 만들수 있다. 악마 객체로 부터도 다른 악마개체를 만들 수 있다. 어떤 몬스터 객체든지 자신하고 비슷한 몬스터를 만드는 prototype(원형)객체로 사용 할 수 있다.
이를 구현하기 위해서 몬스터 클래스에 순수가상함수인 clone()을 추가한다.
class Monster
{
public:
virtual ~Monster() {}
virtual Monster* clone() = 0;
// Other stuff...
};
C++
복사
몬스터 하위클래스에서는 자신과 자료형과 상태가 같은 새로운 객체를 반환하도록 clone()을 구현해야 한다.
class Ghost : public Monster {
public:
Ghost(int health, int speed)
: health_(health),
speed_(speed)
{}
virtual Monster* clone()
{
return new Ghost(health_, speed_);
}
private:
int health_;
int speed_;
};
C++
복사
몬스터를 상속받는 모든 클래스에 clone메서드가 있다면, 스포너에서 종류별로 마들필요 없이 하나만 만들면 가상함수이기 떄문에 알아서 각자의 클래스의 clone 호출될것이다.
class Spawner
{
public:
Spawner(Monster* prototype)
: prototype_(prototype)
{}
Monster* spawnMonster()
{
return prototype_->clone();
}
private:
Monster* prototype_;
};
C++
복사
스포너 클래스는 내부에 몬스터 객체가 숨어 있다. 이 객체는 벌집을 떠나지 않는 여왕벌처럼 자기와 같은 Monster를 도장처럼 찍어낸다.
유령 스포너를 마들려면 원형으로 사용할 유령 스포너를 만든후에 스포너에 전달한다.
Monster* ghostPrototype = new Ghost(15, 3);
Spawner* ghostSpawner = new Spawner(ghostPrototype);
Monster* demonPrototype = new Demon(15, 3);
Spawner* demonSpawner = new Spawner(demonPrototype);
// to do ....
// 계속 추가 될것이다 몬스터 종류가 늘어날 때마다.
C++
복사
프로토타입 패턴의 좋은 점은 클래스 뿐만 아니라 상태도 같이 복제한다는 점이다. 즉 원형으로 사용할 유령 객체를 잘 설정하면 빠른 유령, 느린 유령 등등의 스포너같은 것도 쉽게 만들 수 있다.
프로토 타입 패턴은 우아하면서도 놀랍다. 또한 간단하기 떄문에 따로 외우려 하지 않아도 이해가 된다.
잘 작동 하는가?
이제 몬스터마다 스포너 클래스를 따로 만들지 않아도 된다. 그래도 몬스터마다 clone()은 따로 구현은 해줘야한다.
clone()만들다 보면 애매할 떄가 있따. 객체를 깊은 복사를 해야 할까? 얕은 복사를 해야 할까?
악마 몬스터가 삼지창을 들고있다. 복제된 악마도 삼지창을 들고 있어야 할까?
프로토타입 패턴은 써도 코드양자체는 크게 줄어들지 않는다. 예부터가 현실적이지 않다. 요즘 나오는 왠만한 게임들은 몬스터마다 클래스르 따로 만들지 않는다.
우리프로그래머들은 오랜시간동안 노가다를 통해서 상속구조가 복잡하면 유지보수가 힘들다는 점을 체득했다. 요즘은 개체 종류별로 클래스를 만들기 보다는 컴포넌트, 타입 객체로 모델링 하는 것을 선호한다.
스폰함수를 활용 하는 방법
앞에서 모든 몬스터마다 별도의 스포너 클래스가 필요 했다. 하지만 이 모든 일에는 답이 여러개 있는 법이다. 다음과 같은 스폰함수를 만들어보자
Monster* spawnGhost()
{
return new Ghost();
}
Monster* spawnDemon()
{
return new Demon();
}
....
C++
복사
몬스터 종류마다 클래스를 만드는 것보다 코드양이 더 적다. 이제 스포너클래스는 하나의 포인터 변수만 두면 된다.
typedef Monster* (*SpawnCallback)();
class Spawner
{
public:
Spawner(SpawnCallback spawn)
: spawn_(spawn)
{}
Monster* spawnMonster()
{
return spawn_();
}
private:
SpawnCallback spawn_;
};
C++
복사
유령을 스폰하는 객체는 이렇게 만들 것이다.
Spawner* ghostSpawner = new Spawner(spawnGhost);
Spawner* demonSpawner = new Spawner(spawnDemon);
...
C++
복사
템플릿을 활용하는 방법
요즘에는 템플릿도 많이 활용한다. 스포너클래스를 이용해 인스턴스를 생성하고 싶지만 특정 몬스터 클래스를 하드코딩하기 싫다면 몬스터 클래스 템플릿 타입매개변수를 전달하면 된다.
class Spawner
{
public:
virtual ~Spawner() {}
virtual Monster* spawnMonster() = 0;
};
template <class T>
class SpawnerFor : public Spawner
{
public:
virtual Monster* spawnMonster() { return new T(); }
};
C++
복사
사용방법은 다음과 같다.
Spawner* ghostSpawner = new SpawnerFor<Ghost>();
Spawner* demonSpawner = new SpawnerFor<Demon>();
...
C++
복사
앞에서 본 두 방법을 통해서 Spawner 클래스에 자료형을 매개변수로 전달 할 수 있다. c++에서는 자룧여이 일급 자료형이 아니다 보니 이런 곡예를 해야한다. 파이선, 자바스크립트 등등의 언어에서는 동적 자료형이 있기때문에 더 쉽게 해결할수 있다.
이부분 부터는 프로토타입 의 역사 입니다. 참고사항
프로토 타입 언어 패러다임
OOP(객체지향 프로그래밍)에서 가장 중요한건 상태(변수), 행동(함수)을 함께 묶는데 있다.
클래스만이 이를 위한 유일한 방법일 거라고 생각 할지 모르나, 몇몇사람들은 다르게 생각했다. 이들은 Self라는 언어를 마들었다. oop에서 할수 있는건 다할 수 있지만 클래스는 없는 언어이다.
셀프
순수하게 의미만 놓고 보면 셀프는 클래스 기반 언어보다 더 객체지향적이다. 상태와 동작을 같이 묶어놓은것을 oop라고 할때 클래스 기반 언어는 상태와 동작사이에 분명한 구별이 있다.
친숙한 클래스 기반 언어를 하나 떠올려보자. 객체 상태를 알기 위해서는 해당 인스턴스의 메모리에 접근하여 들여돠바야한다. 즉 상태는 객체가 가지고 있다.
반대로 메서드를 호출한떄는 클래스에 접근한다. 즉 동작은 클래스 (멤버함수) → 코드영역에 존재한다.
셀프에는 이런 구별이 없다. 무엇이든 객체에서 바로 찾을 수 있다. 인스턴스는 상태와 동작 둘다 가질 수 있다. 유일무이한 메서드를 가진 객체도 만들수 있다.
이게 셀프의 전부라면 그다지 유용하지 않을수도 있다. 클래스 기반 언어에서는 상속은 나름 단점도 있지만 , 다형성 통해서 코드를 재사용하고 중복코드를 줄일수 잇다는 장점이 있었다. 클래스 없이 이러한 일을 수행하기 위해 셀프에서는 위임(deligation)이 있다.
먼저 해당 객체에서 필드나 메서드를 찾아본다. 있다면 그걸 쓰고 없다면 상위 객체를 찾아본다.
상위 객체는 그냥 다른 객체의 레퍼런스(&) 일 뿐이다. 첫 번째 객체에 속성이 없다면 상위 객체를 살펴보고, 그래도 없다면 상위 객체의 상위 객체에서 또 찾아보고, 이를 반복하낟. 다시말해 찾아보고 없으면 상위객체 위임한다.
클래스는 new GameObject() 식으로 생성한다. 자기자신의 인스턴스 생서기이다.(factory)
클래스가 없다면 어떻게 객체를 만들수 있을까? 프로타타입 패턴에서 본것처럼 복제하면된다.
셀프에서는 모든 객체가 프로토타입 패턴을 저절로 지원하게 하는것과 같다. 모든 객체가 복사될 수 있기 때문에 비슷한 객체를 여럿 만들려면 다음과 같다.
1.
객체 하나를 원하는 상태로 만든다. 시스템에서 제공하는 기본 Object 객체를 복사한 뒤에 필드와 메서드를 넣어준다.
2.
원하는 만큼 복사한다.
셀프에서는 귀찮게 직접 클론 메서드를 구현하지 않아도 시스템적으로 프로토타입 패턴기반으로 제공해준다.
이방법으로 프로그래밍을 하다보니 생각보다 재미없는 사실을 발견했다. 직접 그런방식의 언어를 만들어보니 클래스가 제공해주는 구조(상속,, 가상함수,, 템플릿)가 아쉬워서 결국에는 언어에서 제공하지 않는 기능을 전부 프로그래머가 만들어 줘야 했다.
데이터모델링을 위한 프로토 타입
시간이 지날수록 게임 데이터는 용량이 엄청 커지고 있다. 초창기 게임은 플로피디스크, cd 등 작은용량에서도 실행하기위해서 게임을 만들어야 했다. 요즘게임에서 코드는 게임을 실행하기 위한 엔진일뿐 콘텐츠는 모두 데이터 정의 되어 있다.
하지만 많은 데이터를 옮기면 대규모 프로젝트의 구조 문제가 오히려 어려워진다면 모를까 저절로 해결되지 않는다. 프로그래밍 언어를 사용하는 이유는 복잡성을 제어 할 수 있는 수단을 가지고 있어서다. 같은 코드를 여기저기 붙여 넣는 대신, 하나의 함수를 만들어 호출한다. 여러 클래스에서 같은 메서드를 복사하는 대신에 따로 클래스를 만들어 합친다.
게임 데이터도 일정 규모이상 커지면 코드와 비슷한 기능한다. 데이터 모델링은 가볍게 다룰만한 주제가 아니긴하지만 프로토타입과 위임을 활용해서 재사용하는 기법을 소개한다.
예를들어 많이사용하는 구조는 json 구조이다. 키/값으로 이루어진 데이터다.
고블린을 정의한다면 다음과같이 정의가 될것이다.
{
"name": "goblin grunt",
"minHealth": 20,
"maxHealth": 30,
"resists": ["cold", "poison"],
"weaknesses": ["fire", "light"]
}
JSON
복사
워낙 간단하기 떄문에 텍스트 데이터를 꺼려하는 기획자도 쉽게 각업할수 있다.
{
"name": "goblin wizard",
"minHealth": 20,
"maxHealth": 30,
"resists": ["cold", "poison"],
"weaknesses": ["fire", "light"],
"spells": ["fire ball", "lightning bolt"]
}
{
"name": "goblin archer",
"minHealth": 20,
"maxHealth": 30,
"resists": ["cold", "poison"],
"weaknesses": ["fire", "light"],
"attacks": ["short bow"]
}
JSON
복사
이렇게 놓고보니까 중복된 데이터들이 많다. 이게 만약에 코드였다면 거슬렷을 것이다. 상속이나 이런것들 이용해서 중복을 제거했을 터이다. 하지만 json에는 이런 기능이 없다.
그러면은 조금더 효율적으로 만들어보자
{
"name": "goblin grunt",
"minHealth": 20,
"maxHealth": 30,
"resists": ["cold", "poison"],
"weaknesses": ["fire", "light"]
}
{
"name": "goblin wizard",
"prototype": "goblin grunt",
"spells": ["fire ball", "lightning bolt"]
}
{
"name": "goblin archer",
"prototype": "goblin grunt",
"attacks": ["short bow"]
}
JSON
복사
궁수와 마법사의 프로토타입으로 보병을 지정했기 때문에 중복된 데이터들을 따로 입력하지 않아도 된다.
세가지 실제 고블린을 위임할 기본 고블린 같은 프로토타입을 따로 만들지 않았다는 점에서 흥미롭다. 대신 가장 단순한 고블린 자료형을 하나 더 골라서 다른객체 위임했다.
프로토타입 기반 시스템에서는 새로 정의되는 객체를 만들 때 어떤 객체든 복제로 사욯 하는게 당연한데, 이번 데이터 예제에서도 마찬가지다. 특히 일회성 특수 객체가 자주나오는 게임에 잘 맞는 방식이다. 보스와 유니크 아이템은 일반 아이템을 약간 다듬어서 만들 떄가 많으므로 프로토타입 방식의 위임을 써먹기 좋다.
{
"name": "Sword of Head-Detaching",
"prototype": "longsword",
"damageBonus": "20"
}
JSON
복사
데이터 모델링 시스템에 기능을 약간 추가 했을 뿐이지만, 기획자는 기존 무기나 몬스터를 약간 변형해 게임월드를 쉽게 풍성하게 만들어 줄수 있다.
원문 전체 해설·코드·도해
위의 수업용 정리와 실습을 먼저 읽은 뒤, 아래에서 책의 원문 흐름·모든 코드·원본 도해·각주를 빠짐없이 확인할 수 있습니다.
프로토타입 패턴(Prototype Pattern)
원형이 되는 인스턴스로 생성할 객체의 종류를 지정하고, 그 견본을 복사해 새로운 객체를 만듭니다.
— 『GoF의 디자인 패턴』 한국어판 169쪽
저자는 『GoF의 디자인 패턴』에서 ‘프로토타입’이라는 말을 처음 접했습니다. 일상적으로 말하는 프로토타입은 이 디자인 패턴과는 다릅니다. 이 장에서는 원래 패턴을 살펴본 뒤, 더 넓은 의미의 프로토타입이라는 용어와 개념이 어떻게 사용되는지 다룹니다.
원주 1·옮긴이: 『GoF의 디자인 패턴』에서는 ‘원형’으로 옮겼습니다.
원주 2: GoF는 이반 서덜랜드가 1963년에 만든 스케치패드(Sketchpad)를 이 패턴의 첫 사례로 듭니다. 다른 사람들이 밥 딜런이나 비틀스를 들을 때 그는 CAD, 그래픽 인터페이스, 객체지향의 기본 개념을 만들고 있었다는 설명입니다. 원문은 유튜브의 ‘Ivan Sutherland – Sketchpad Demo’ 영상(수록 링크)을 권합니다.
5.1 프로토타입 디자인 패턴
〈건틀릿(Gauntlet)〉 같은 게임을 만든다고 해 봅시다. 몬스터들은 영웅을 잡아먹으려고 몰려다닙니다. 이 음침한 녀석들은 스포너(spawner)를 통해 레벨에 등장하며, 종류마다 스포너가 따로 있습니다.
원주 3·옮긴이: 〈건틀릿〉은 1985년에 처음 출시된 탑다운 방식의 핵앤슬래시 게임입니다. 책 집필 당시까지 후속작이 이어졌습니다.
예제를 위해 유령·악마·마법사마다 클래스를 만듭니다.
class Monster {
// 기타 등등...
};
class Ghost : public Monster {};
class Demon : public Monster {};
class Sorcerer : public Monster {};
C++
복사
스포너 하나는 한 종류의 몬스터만 만든다고 해 보겠습니다. 모든 몬스터에 대응하도록 각 몬스터마다 별도 스포너 클래스를 만들면, 스포너의 상속 구조가 몬스터 상속 구조를 그대로 따라갑니다.
그림 5-1. 몬스터와 스포너에 같은 모양의 상속 구조가 생깁니다
원주 4: 저자는 이 그림을 그리려고 먼지 쌓인 UML 책을 꺼냈다고 합니다. 삼각형 끝이 있는 화살표는 ‘상속한다’는 뜻이며, 화살표 끝이 상위 클래스를 가리킵니다.
코드로 쓰면 다음과 같습니다.
class Spawner {
public:
virtual ~Spawner() {}
virtual Monster* spawnMonster() = 0;
};
class GhostSpawner : public Spawner {
public:
virtual Monster* spawnMonster() {
return new Ghost();
}
};
class DemonSpawner : public Spawner {
public:
virtual Monster* spawnMonster() {
return new Demon();
}
};
// 나머지 몬스터도 같은 방법으로 정의합니다.
C++
복사
코드를 많이 쓸수록 돈을 더 받는다면 모르겠지만 좋은 모습은 아닙니다. 클래스도 많고, 행사코드도 많고, 반복과 중복도 많습니다. ‘많다’는 말까지 많아집니다.
원주 5·옮긴이: 행사코드는 실행 동작 자체보다는 프로그래밍 언어의 문법적 형식을 맞추기 위해 필요한 코드를 가리킵니다. 『폴리글랏 프로그래밍』(한빛미디어, 2014) 13쪽을 참고합니다.
프로토타입 패턴은 이 문제를 다르게 풉니다. 객체가 자신과 비슷한 객체를 만들어 낼 수 있게 하는 것입니다. 유령 하나에서 여러 유령을, 악마 하나에서 여러 악마를 만듭니다. 어떤 몬스터든 새로운 몬스터의 원형이 될 수 있습니다.
먼저 상위 클래스에 추상 메서드 clone()을 추가합니다.
class Monster {
public:
virtual ~Monster() {}
virtual Monster* clone() = 0;
};
C++
복사
각 하위 클래스는 자료형과 상태가 같은 새 객체를 반환하게 구현합니다. 유령의 경우는 다음과 같습니다.
class Ghost : public Monster {
public:
Ghost(int health, int speed)
: health_(health), speed_(speed) {}
virtual Monster* clone() {
return new Ghost(health_, speed_);
}
private:
int health_;
int speed_;
};
C++
복사
모든 몬스터에 clone()이 있다면 스포너 클래스는 하나면 됩니다.
class Spawner {
public:
Spawner(Monster* prototype) : prototype_(prototype) {}
Monster* spawnMonster() {
return prototype_->clone();
}
private:
Monster* prototype_;
};
C++
복사
스포너 안에는 원형 몬스터가 숨어 있습니다. 벌집을 떠나지 않는 여왕벌처럼, 같은 몬스터를 도장 찍듯 만들어 내는 역할만 합니다.
그림 5-2. 스포너가 보관한 원형 몬스터를 clone()으로 복제합니다
유령을 만드는 스포너는 원형 유령을 먼저 만든 다음 전달하면 됩니다.
Monster* ghostPrototype = new Ghost(15, 3);
Spawner* ghostSpawner = new Spawner(ghostPrototype);
C++
복사
클래스뿐 아니라 상태까지 복제한다는 것이 장점입니다. 원형의 값을 설정하면 빠른 유령, 약한 유령, 느린 유령을 만드는 스포너도 쉽게 만들 수 있습니다. 우아하고 간단해서 별도로 외우려 하지 않아도 기억하기 좋은 패턴입니다.
생성할 클래스를 알려 주는 대신 견본을 건넵니다.
Ghost(15, 3)을 원형으로 넣으면 체력 15·속도 3의 유령을 계속 만듭니다. 속도 7의 원형을 다른 스포너에 넣으면 그 스포너는 빠른 유령을 만듭니다. 단순히 ‘Ghost라는 종류’만 고르는 것보다 어떤 상태로 시작할지도 함께 지정할 수 있습니다.
얼마나 잘 작동하는가?
몬스터별 스포너 클래스는 없어졌습니다. 하지만 몬스터마다 clone()을 작성해야 하므로 코드 양이 크게 줄지는 않았습니다.
복제의 의미도 애매할 수 있습니다. 깊은 복사(deep clone)를 할까요, 얕은 복사(shallow clone)를 할까요? 악마가 삼지창을 들고 있다면 복제한 악마도 삼지창을 가져야 할까요? 그렇다면 같은 삼지창을 함께 참조할지, 별도 삼지창도 복제할지 결정해야 합니다.
게다가 예제 자체가 현대적인 엔진과는 거리가 있습니다. 개발자들은 복잡한 상속 계층의 유지보수가 어렵다는 사실을 경험했습니다. 그래서 몬스터 종류마다 클래스를 만들기보다 컴포넌트(14장)나 타입 객체(13장)로 모델링하는 방식을 선호합니다.
복제할 데이터와 공유할 자원을 구별합니다.
체력 같은 숫자는 복사하기 쉽습니다. 인벤토리처럼 바뀌는 상태는 두 몬스터가 독립적으로 가져야 할 수 있고, 같은 메시나 텍스처는 공유해도 됩니다. clone()이라는 이름만으로 소유권이나 복사 깊이가 정해지는 것은 아닙니다. ‘한쪽을 바꿨을 때 다른 쪽도 바뀌어야 하는가’를 항목마다 생각해 보세요.
스폰 함수
앞에서는 종류별 스포너 클래스를 만들었지만 다른 답도 있습니다. 생성 함수를 정의하면 됩니다.
Monster* spawnGhost() {
return new Ghost();
}
C++
복사
종류마다 클래스를 정의하는 것보다 행사코드가 적습니다. 스포너는 함수 포인터 하나를 저장합니다.
typedef Monster* (*SpawnCallback)();
class Spawner {
public:
Spawner(SpawnCallback spawn) : spawn_(spawn) {}
Monster* spawnMonster() { return spawn_(); }
private:
SpawnCallback spawn_;
};
C++
복사
유령 스포너는 이렇게 만듭니다.
Spawner* ghostSpawner = new Spawner(spawnGhost);
C++
복사
템플릿
C++ 개발자에게는 템플릿도 익숙한 선택입니다. 스포너가 특정 몬스터 클래스에 고정되지 않게 하려면 몬스터 자료형을 템플릿 매개변수로 받으면 됩니다.
class Spawner {
public:
virtual ~Spawner() {}
virtual Monster* spawnMonster() = 0;
};
template <class T>
class SpawnerFor : public Spawner {
public:
virtual Monster* spawnMonster() { return new T(); }
};
C++
복사
원주 6: C++ 개발자가 템플릿을 좋아하게 되는 것인지, 템플릿에 좌절한 사람이 C++을 떠나는 것인지는 모르겠지만 자신이 본 개발자들은 모두 잘 쓴다는 저자의 농담입니다.
원주 7: 공통 상위 클래스 Spawner를 따로 두어야 어떤 몬스터를 만드는지 모르는 코드에서도 스포너를 다룰 수 있습니다. SpawnerFor<T>만 있으면 사용하는 쪽에서도 매번 템플릿 매개변수를 알아야 합니다. 생성 결과는 공통 `Monster`로 받습니다.*
사용법은 다음과 같습니다.
Spawner* ghostSpawner = new SpawnerFor<Ghost>();
C++
복사
대안을 비교하는 예제입니다.
함수와 템플릿 예제의 new Ghost()는 앞의 원형 복제 구현과 다른 설계안을 보여 줍니다. 앞에서 Ghost(int, int) 생성자만 정의한 클래스에 그대로 합치면 기본 생성자가 없으므로 컴파일되지 않습니다. 기본 생성자를 제공하거나 필요한 인수를 받는 생성 함수를 준비해야 합니다.
일급 자료형
앞의 두 방법은 스포너에 자료형을 매개변수처럼 전달하게 합니다. C++에서는 자료형 자체가 런타임의 일급 값이 아니므로 이런 우회가 필요합니다.
파이썬·루비 등 클래스를 값으로 전달할 수 있는 언어에서는 더 직접적으로 풉니다. 자바스크립트에서도 생성자 함수를 값으로 전달할 수 있습니다. 원하는 몬스터의 클래스나 생성자를 실제 런타임 객체로 스포너에 전달하면 됩니다.
원주 8: 일급 자료형이 없어도 타입 객체 패턴(13장)을 대안으로 사용할 수 있습니다. 일급 자료형이 있는 언어에서도 언어가 제공하는 클래스와 다른 방식으로 ‘자료형’을 정의하고 싶다면 타입 객체는 유용합니다.
저자는 여러 선택지가 있기 때문에 프로토타입 디자인 패턴이 언제 가장 이상적인지는 확신하지 못한다고 말합니다. 사람마다 판단은 다를 수 있습니다. 이제 디자인 패턴을 넘어 언어 패러다임으로서의 프로토타입을 살펴봅니다.
5.2 프로토타입 언어 패러다임
객체지향 프로그래밍(OOP)이라고 하면 흔히 클래스를 떠올립니다. OOP를 정의하는 방식은 종교의 종파만큼 다양하지만, 데이터와 코드를 묶은 객체를 직접 정의한다는 점에는 대체로 동의합니다. C 같은 구조적 언어나 스킴 같은 함수형 언어와 비교하면 상태와 동작을 함께 묶는 것이 특징입니다.
클래스만이 이를 구현하는 유일한 수단은 아닙니다. 데이비드 엉거(David Ungar), 랜덜 스미스(Randall Smith) 등은 1980년대 셀프(Self)라는 언어를 만들었습니다. 이 언어는 객체지향 기능을 제공하지만 클래스는 없습니다.
셀프
상태와 동작을 함께 묶는 것이 OOP라면, 의미만 놓고 볼 때 셀프는 클래스 기반 언어보다 더 객체지향적이라고 할 수 있습니다. 클래스 기반 언어에는 둘 사이에 분명한 구분이 있기 때문입니다.
익숙한 클래스 기반 언어에서 객체의 상태를 보려면 해당 인스턴스의 메모리를 봅니다. 상태는 인스턴스에 있습니다. 반면 메서드를 찾을 때는 인스턴스의 클래스를 확인합니다. 동작은 클래스 쪽에 있으며, 호출하려면 한 단계를 거칩니다.
그림 5-3. 클래스는 메서드를, 인스턴스는 필드를 보관합니다
원주 9: 예를 들어 C++의 가상 메서드를 호출할 때는 인스턴스에서 vtable 포인터를 얻고, vtable에서 메서드를 찾습니다.
셀프에는 이런 구분이 없습니다. 상태와 동작을 모두 객체에서 찾습니다. 인스턴스가 둘을 함께 가질 수 있으며, 그 객체만의 독특한 메서드도 만들 수 있습니다.
그림 5-4. 사람은 섬이 아니라지만, 셀프 객체는 다릅니다. 필드와 메서드가 한 객체에 있습니다
이것만으로는 충분히 유용해 보이지 않을 수 있습니다. 클래스 상속에는 단점이 있지만 다형성을 통해 코드를 재사용하고 중복을 줄일 수 있다는 장점도 있습니다. 셀프는 클래스 없이 이를 제공하기 위해 위임(delegation)을 사용합니다.
먼저 현재 객체에서 필드나 메서드를 찾습니다. 있으면 사용하고, 없으면 상위(parent) 객체에 물어봅니다. 상위 객체도 다른 객체를 참조하는 것일 뿐입니다. 거기에도 없으면 그 상위로 계속 올라갑니다. 즉 찾지 못한 요청을 위임합니다.
그림 5-5. 없는 속성은 상위 객체에 위임해 찾습니다
원주 10: 실제 셀프는 다중 상위 객체를 지원합니다. 상위 객체를 가리키는 것은 특별한 필드이므로 실행 중 그 필드를 바꾸는 동적 상속도 가능합니다.
여러 객체가 상위 객체의 동작뿐 아니라 상태도 재사용하므로 클래스가 제공하는 기능 대부분을 대신할 수 있습니다. 클래스의 또 다른 역할은 인스턴스를 만드는 것입니다. 많은 언어에서 new Thingamabob() 같은 문법을 사용하므로, 클래스는 자기 인스턴스의 생성기(factory) 역할도 합니다.
클래스가 없는 셀프에서는 어떻게 같은 내용의 객체를 여러 개 만들까요? 앞의 프로토타입 패턴처럼 복제합니다. 모든 객체가 기본적으로 복제를 지원하므로 다음 순서로 작업할 수 있습니다.
1.
기본 Object 객체를 복제한 뒤 필드와 메서드를 채워 원하는 견본을 만듭니다.
2.
그 객체를 필요한 개수만큼 복제합니다.
직접 clone()을 구현하지 않고도 프로토타입 패턴의 우아함을 언어가 제공합니다. 저자는 이 아름답고 똑똑하며 가벼운 시스템에 매료되어 더 배우고자 직접 언어를 만들기 시작했습니다.
그래서 어떻게 됐을까?
처음에는 순수 프로토타입 언어를 다루는 것이 즐거웠습니다. 하지만 저자는 실제로 프로그래밍하는 과정은 그리 재미있지 않다는 결론에 이릅니다.
언어 구현이 간단해진 이유는 복잡한 일을 사용자에게 떠넘겼기 때문이었습니다. 직접 사용하자 클래스가 제공하던 구조가 그리워졌고, 언어에 없는 기능을 라이브러리로 다시 만들어야 했습니다.
자신이 클래스에 너무 익숙하기 때문일 수도 있다고 인정합니다. 그래도 많은 사람에게 잘 정의된 ‘객체 묶음’이라는 개념이 더 편할 것이라고 생각합니다.
클래스 기반 언어의 성공뿐 아니라 게임도 비슷한 모습을 보입니다. 캐릭터 클래스, 적, 아이템, 스킬을 명확한 종류로 구분합니다. 모든 몬스터가 “트롤과 고블린이 반반 섞이고 뱀도 조금 들어간” 식으로 제각각인 게임은 흔하지 않습니다.
프로토타입은 널리 알리고 싶은 훌륭한 개념이지만 매일 그 방식만으로 작성하지 않아도 되어 다행이라고 저자는 말합니다. 순수 프로토타입 코드는 자신에게 너무 두루뭉술하고 이해하기 어려웠기 때문입니다.
원주 12: 다른 셀프 개발자도 비슷한 결론에 이르렀다는 사실을 훗날 알았다고 합니다. 그렇다고 프로젝트가 무의미했던 것은 아닙니다. 극도로 동적인 언어를 빠르게 만들기 위해 개발한 JIT 컴파일, GC, 메서드 디스패치 최적화는 이후 널리 쓰이는 동적 언어에도 적용됐습니다. 셀프를 최적화한 개발자가 다른 언어에 같은 기법을 적용한 경우도 있습니다.
원주 13: 저자는 순수 프로토타입 방식으로 작성한 실제 코드는 거의 찾을 수 없었다며, 자신도 찾아봤다고 덧붙입니다. 이 대목은 언어 방식에 관한 저자의 경험과 평가입니다.
자바스크립트는 어떤가?
프로토타입 방식이 그렇게 접근하기 어렵다면, 많은 사람이 매일 사용하는 자바스크립트는 어떻게 설명할 수 있을까요?
브렌던 아이크는 셀프에서 직접 영향을 받았습니다. 자바스크립트 객체에는 임의의 속성을 넣을 수 있습니다. 필드도 메서드도 속성이며, 메서드는 함수가 저장된 속성입니다. 객체는 ‘프로토타입’이라는 다른 객체를 가리키고, 자신에게 없는 속성을 그쪽에 위임할 수 있습니다.
원주 14: 언어를 만드는 입장에서는 프로토타입이 클래스보다 구현하기 쉽습니다. 브렌던 아이크는 이 장점을 활용해 첫 자바스크립트 버전을 열흘 만에 만들었습니다.
그럼에도 저자는 자바스크립트의 통상적인 사용법이 순수 프로토타입 방식보다 클래스 방식에 더 가깝다고 봅니다. 셀프에서 핵심이었던 복제를 자바스크립트에서는 같은 방식으로 제공하지 않는다는 점을 듭니다.
비슷한 기능인 Object.create()는 기존 객체에 위임하는 새 객체를 만듭니다. 이것도 자바스크립트 탄생 14년 뒤인 ECMAScript 5에서 들어왔습니다. 원서가 설명하는 일반적인 작성법은 먼저 생성자 함수를 정의하는 것입니다.
function Weapon(range, damage) {
this.range = range;
this.damage = damage;
}
JavaScript
복사
다음처럼 호출합니다.
var sword = new Weapon(10, 16);
JavaScript
복사
new는 새 빈 객체를 만든 뒤 그 객체를 this에 연결해 Weapon을 호출합니다. 함수는 그 객체의 필드를 초기화합니다.
new는 또 새 객체와 프로토타입의 위임 관계를 만듭니다. 이 프로토타입 객체는 Weapon.prototype으로 접근할 수 있습니다. 상태는 생성자에서 추가하고, 공통 동작은 보통 프로토타입에 추가합니다.
Weapon.prototype.attack = function(target) {
if (distanceTo(target) > this.range) {
console.log("Out of range!");
} else {
target.health -= this.damage;
}
};
JavaScript
복사
attack이라는 함수 속성이 프로토타입에 추가됐습니다. new Weapon()으로 만든 객체들은 Weapon.prototype으로 위임하므로 sword.attack()은 이 함수를 찾습니다.
그림 5-6. sword는 상태를 갖고, Weapon.prototype은 공유하는 attack 메서드를 갖습니다
여기까지의 구조를 다시 살펴보면 다음과 같습니다.
•
자료형을 정의하는 생성자 함수를 new와 함께 호출해 객체를 만듭니다.
•
상태는 각 인스턴스에 저장합니다.
•
같은 자료형이 공유할 동작은 별도 프로토타입 객체에 저장하고 위임으로 간접 접근합니다.
저자는 이것이 앞서 설명한 클래스 구조와 상당히 닮았다고 봅니다. 자바스크립트에서도 프로토타입 방식으로 작성할 수 있지만 관용구와 문법은 클래스와 비슷한 작성법을 유도합니다. 순수 프로토타입 방식이 어렵게 느껴졌던 저자는 오히려 이런 포장이 마음에 든다고 말합니다.
복제와 위임은 다른 동작입니다.
복제는 새 객체에 값을 옮깁니다. 위임은 속성을 찾지 못했을 때 다른 객체를 계속 찾아갑니다. Object.create(source)를 ‘source의 모든 속성을 복사한다’고 읽으면 안 됩니다. 이 절은 원서 집필 당시의 문법과 저자의 평가를 설명하며, 현대 문법을 쓰더라도 두 개념의 구별은 그대로 필요합니다.
5.3 데이터 모델링을 위한 프로토타입
프로토타입의 어려움만 나열하다 보니 장이 우울해졌습니다. 저자는 비극이 아니라 희극 같은 책을 쓰고 싶었다며, 프로토타입, 특히 위임이 유용한 분야를 보면서 마무리합니다.
게임에서 코드보다 데이터가 차지하는 비중은 커져 왔습니다. 작은 플로피나 카트리지에 담아야 했던 시절에는 많은 것을 절차적으로 생성했습니다. 이후 게임에서는 코드는 실행 엔진의 역할을 하고 콘텐츠는 데이터로 정의하는 경우가 많아졌습니다.
좋은 변화지만, 콘텐츠를 데이터로 옮긴다고 구조 문제가 저절로 사라지는 것은 아닙니다. 오히려 더 어려워질 수도 있습니다. 프로그래밍 언어는 복잡성을 관리하는 수단을 제공합니다. 코드를 복사하지 않고 함수를 호출하며, 여러 클래스에 같은 메서드를 붙여 넣는 대신 상속이나 믹스인으로 재사용합니다.
데이터가 커지면 비슷한 수단이 필요합니다. 데이터 모델링 전체를 여기서 다룰 수는 없지만, 위임으로 재사용하는 방법 하나를 살펴봅니다.
원주 15·옮긴이: 믹스인(mixin)은 상속받지 않고 다른 클래스의 코드를 재활용하는 기법을 말합니다.
앞의 〈건틀릿〉 같은 게임에서 기획자가 몬스터와 아이템 속성을 파일로 정의한다고 해 봅시다. 이런 용도로 JSON을 많이 씁니다. 키와 값으로 이루어진 데이터 개체는 맵, 속성 목록 등 여러 이름으로도 불립니다. 프로그래머가 기존 개념에 새 이름을 붙이기를 좋아하기 때문이라는 저자의 농담도 붙습니다.
원주 16: 여기서 말하는 것은 기존 탑다운 멀티플레이어 던전 크롤 게임의 영향을 전혀 받지 않은 독창적인 게임이니 고소하지 말아 달라는 농담입니다.
원주 17: 스티브 예기(Steve Yegge)는 이런 개념이 계속 재발명되는 것을 ‘범용 디자인 패턴(Universal Design Pattern)’이라고 불렀습니다. 원문 수록 링크.
고블린 보병은 다음과 같이 정의합니다.
{
"이름": "고블린 보병",
"기본체력": 20,
"최대체력": 30,
"내성": ["추위", "독"],
"약점": ["불", "빛"]
}
JSON
복사
간단하므로 텍스트 데이터에 익숙하지 않은 기획자도 작업할 수 있습니다. 같은 집안의 고블린 마법사와 궁수도 만들어 봅니다.
{
"이름": "고블린 마법사",
"기본체력": 20,
"최대체력": 30,
"내성": ["추위", "독"],
"약점": ["불", "빛"],
"마법": ["화염구", "번개 화살"]
}
JSON
복사
{
"이름": "고블린 궁수",
"기본체력": 20,
"최대체력": 30,
"내성": ["추위", "독"],
"약점": ["불", "빛"],
"공격방법": ["단궁"]
}
JSON
복사
중복이 많습니다. 코드였다면 바로 눈에 거슬렸을 것입니다. 중복은 공간과 제작 시간을 낭비하고, 두 데이터가 같은지 확인하려면 모든 필드를 비교해야 합니다. 모든 고블린을 강하게 만들려면 셋의 체력을 빠짐없이 고쳐야 하므로 유지보수도 어렵습니다.
코드라면 공통 goblin 자료형을 만들고 재사용했을 것입니다. JSON 자체에는 그런 기능이 없지만 읽는 시스템에 기능을 더할 수 있습니다.
프로토타입 필드가 위임할 다른 데이터 객체의 이름을 지정한다고 약속합니다. 현재 객체에 속성이 없으면 그 필드가 가리키는 객체에서 찾습니다.
원주 18: 프로토타입은 게임 속 고블린의 실제 특성이 아니라 메타데이터입니다. 고블린에게 거친 초록 피부와 누런 이빨은 있지만 ‘프로토타입’은 없습니다. 이것은 고블린을 표현하는 데이터 객체의 속성입니다.
이제 데이터를 다음과 같이 줄일 수 있습니다.
{
"이름": "고블린 보병",
"기본체력": 20,
"최대체력": 30,
"내성": ["추위", "독"],
"약점": ["불", "빛"]
}
JSON
복사
{
"이름": "고블린 마법사",
"프로토타입": "고블린 보병",
"마법": ["화염구", "번개 화살"]
}
JSON
복사
{
"이름": "고블린 궁수",
"프로토타입": "고블린 보병",
"공격방법": ["단궁"]
}
JSON
복사
궁수와 마법사는 보병에 위임하므로 체력·내성·약점을 반복 입력하지 않습니다. 단순한 위임 기능으로 많은 중복을 없앴습니다.
여기서 추상적인 ‘기본 고블린’을 따로 만들지 않았다는 점이 흥미롭습니다. 실제로 사용되는 가장 단순한 종류를 골라 다른 객체가 위임하게 했습니다.
프로토타입 시스템에서는 어떤 객체든 새 객체의 바탕이 될 수 있습니다. 데이터에서도 같은 장점이 있으며, 특히 일회성 특수 개체가 많은 게임에 잘 맞습니다.
보스나 유니크 아이템은 일반 개체를 조금 바꾸어 만드는 경우가 많습니다. 일반 롱소드에 보너스를 붙인 ‘참수의 마법검’은 다음처럼 표현할 수 있습니다.
{
"이름": "참수의 마법검",
"프로토타입": "롱소드",
"보너스대미지": "20"
}
JSON
복사
데이터 모델링 시스템에 작은 기능을 더했을 뿐인데 기획자는 기존 무기와 몬스터를 변형해 월드를 풍성하게 만들 수 있습니다. 이런 풍성함이 유저가 원하는 것입니다.
프로토타입 데이터 연습해 보기
고블린 마법사의 최대체력을 요청하면 먼저 마법사 데이터에서 찾고, 없으므로 보병으로 이동해 30을 얻습니다. 마법사에 최대체력: 45를 직접 넣으면 그 값이 우선합니다. JSON 파서가 이 기능을 자동으로 제공하는 것은 아니므로 게임의 데이터 로더에서 구현해야 합니다. 존재하지 않는 원형 이름과 A→B→A처럼 순환하는 위임도 검사해 보세요. 마지막 예제의 "20"은 원문처럼 문자열이며, 계산에 쓰려면 숫자 타입으로 정의하거나 변환해야 합니다.
출처: 사용자가 제공한 『게임 프로그래밍 패턴』 한국어판 5장(인쇄 87~102쪽). 원문 그림 5-1~5-6, 모든 코드·데이터 예제와 원주 1~18을 포함했습니다. 회색
콜아웃은 추가 설명입니다.
콜아웃은 추가 설명입니다.













