기록공간

10장. 행동 패턴 - 바이트코드(Bytecode)(인터프리터) 본문

Game Design Pattern, Logic

10장. 행동 패턴 - 바이트코드(Bytecode)(인터프리터)

입코딩 2020. 4. 12. 17:07
반응형

개발에서 게임분야는 재미있을지는 몰라도, 절대 쉽지 않다. 요즘 게임을 만들기 위해서는 엄청난 양의 소스 코드를 구현해야 한다. 심지어 유통사에서 품질을 엄격히 심사하기 때문에 버그나 크래시가 나는 경우 출시가 불가능해진다.

 

또한 플랫폼의 성능도 최대한 뽑아내야 한다. 게임은 다른 소프트웨어보다 성능이 중요하다. 성능이 나오도록 하기 위해서는 불가피하게 최적화 작업이 필요하다.

 

게임 개발은 성능과 안전을 위해 C++과 같은 중량 언어(Heavyweight language)를 사용한다. 이런 언어는 하드웨어 성능을 최대한 끌어낼 수 있는 저 수준 표현과 버그를 막거나 회피하기 위한 시스템을 함께 제공한다.

 

게임 개발자 되기 위한 비용은 만만치 않다. 몇 년간 집중 훈련이 필요하고, 그런 후에도 엄청난 규모의 코드를 이겨내야 한다. 규모가 큰 게임 빌드 시간은 '커피 한잔 마시고 올' 정도 혹은 그 이상으로 오래 걸리기도 한다.

 

기술력도 중요하지만, 게임에서 가장 중요한 제약조건은 바로 재미이다. 유저들은 신선하면서도 밸런스가 잘 맞는 게임을 원한다. 이런 게임을 만들기 위해서는 반복 개발을 계속해야 하는데, 뭐든 살짝만 고치려고 해도 산더미 같은 저수준 코드를 여기저기 건드려야 하고 느려 터진 빌드를 기다리는 동안 멍 때려야 한다면 몰입 상태에 빠지기 어렵다.


마법 전투!

두 마법사가 어느 한쪽이 이길 때까지 서로에게 마법을 쏘는 대전 게임을 만든다고 해보자. 마법을 코드로 만들어도 되지만, 이러면 프로그래머가 코드를 고쳐야 한다. 기획자가 수치를 약간 바꿔 느낌만 보고 싶을 때에도 게임 전체를 빌드한 뒤에 게임을 다시 실행해봐야 한다.

 

게임은 출시 이후에도 업데이트를 통해 버그를 고치거나 콘텐츠를 추가할 수 있어야 한다. 마법이 전부 하드 코딩되어 있다면, 마법을 바꿀 때마다 게임 실행 파일을 패치해야 한다.

 

더군다나 유저가 직접 마법을 만들 수 있는 모드를 지원해야 한다면? 개발사에서는 소스를 제공해야 하고 유저는 게임을 빌드하기 위한 컴파일러 툴체인을 다 갖춰야 한다. 이렇게 만든 마법에 버그라도 있으면 다른 플레이어마저 크래시 시킬 수 있다.

 

데이터 > 코드

게임 엔진에서 사용하는 개발 언어는 마법을 구현하기에 적합하지 않다. 마법 기능을 게임 코드와 안전하게 격리시킬 필요가 있다. 쉽게 고치고, 쉽게 다시 불러올 수 있고, 게임 실행 파일과는 물리적으로 떼어놓을 수 있으면 좋다.

 

바로 데이터가 이렇다고 생각한다. 행동을 데이터 파일에 따로 정의해놓고 게임 코드에서 읽어서 '실행'할 수만 있다면, 앞에서 말한 모든 목표를 달성할 수 있다.

 

가상 기계어

게임은 실행될 때 플레이어의 컴퓨터가 C++ 문법 트리구조를 런타임에 순회하지는 않는다. 대신 미리 컴파일해놓은 기계어를 실행한다. 기계어의 장점은 다음과 같다.

 

  • 밀도가 높다. 바이너리 데이터가 연속해서 꽉 차있어서 한 비트도 낭비하지 않는다.

  • 선형적이다. 명령어가 모여있고 순서대로 실행된다. 메모리를 넘나들지 않는다.

  • 저수준이다. 각 명령어는 최소한의 작업만 한다.

  • 빠르다. 하드웨어로 직접 구현되어 있어 속도가 굉장히 빠르다.

이런 장점이 좋아 보이기는 해도 마법을 기계어로 구현하고 싶은 생각은 안 든다. 게임에서 실행되는 기계어를 유저에게 제공하는 행위는 해커들에게 너무나도 고마운 행위이다. 그렇기 때문에 기계어는 성능안정성 사이에서 절충해야 한다.

 

실제 기계어를 읽어 바로 실행하는 대신 우리만의 가상 기계어를 정의하면 어떨까? 가상 기계어를 실행하는 간단한 에뮬레이터도 만든다면? 가상 기계어는 실제 기계어처럼 밀도가 높고, 선형적이며, 상대적으로 저수준이지만, 동시에 게임에서 완전히 제어하기 때문에 안전하게 격리할 수 있다.

 

이 에뮬레이터를 가상 머신(Virtual Machine, VM)이라 부르고 VM이 실행하는 가상 바이너리 기계어는 바이트코드라고 부른다. 바이트코드는 유연하고, 데이터로 여러 가지를 쉽게 정의할 수 있으며, 성능도 좋다. (바이트코드는 인터프리터와 거의 동일한 의미이기 때문에 둘을 혼용해 언급한다)


패턴

명령어 집합은 실행할 수 있는 저수준 작업들을 정의한다. 명령어는 일련의 바이트로 인코딩된다. 가상 머신중간 값들을 스택에 저장해가면서 이들 명령어를 하나씩 실행한다. 명령어를 조합함으로써 복잡한 고수준 행동을 정의할 수 있다.

 

언제 쓸 것인가?

바이트코드 패턴은 복잡하고 쉽게 적용하기도 어렵다. 정의할 행동은 많은데 다음과 같은 이유로 바이트코드 패턴을 사용한다.

 

  • 언어가 너무 저수준이라 만드는 데 손이 많이 가거나 오류가 생기기 쉽다.

  • 컴파일 시간이나 다른 빌드 환경 때문에 반복 개발이 너무 오래 걸린다.

  • 보안에 취약하다. 정의하려는 행동이 게임을 크래시 되지 않게 하고 싶다면 나머지 코드로부터 격리해야 한다.

물론, 대부분 게임이 이에 해당한다. 모두가 빠른 반복 개발과 안전성을 원한다. 문제는 그냥 얻을 수 없다는 것이다. 바이트코드는 네이티브 코드(CPU, OS가 직접 실행하는 코드)보다는 느리기 때문에 성능이 민감한 곳에는 적합하지 않다.

 

주의사항

프런트엔드 필요할 것이다

바이너리 바이트코드 형식은 사용자가 작성할 만한 게 아니다. 행동 구현을 코드에서 따로 빼낸 이유는 고수준으로 표현하기 위해서였다. 사용자가에게 어셈블리 언어, 그것도 개발자가 만든 어셈블리어로 행동을 구현하게 하는 건 전혀 개선이 아니다.

 

바이트 코드를 생성할 방법이 있다고 가정하자. 일반적으로 사용자가 고수준 형식으로 원하는 행동을 작성하면 어떤 툴이 이를 가상 머신이 이해할 수 있는 바이트코드로 변환시켜준다. 이 툴이 바로 컴파일러이다.

 

디버거가 그리울 것이다.

프로그래밍은 어려운 일이다. 기계에 어떤 일을 시키고 싶은지 알지만 이를 항상 정확히 표현하지는 못하다 보니 버그가 나온다. 버그를 찾기 위해 도움을 주는 툴은 많다. 디버거, 정적 분석기, 디컴파일러 같은 툴은 어셈블리 언어나 더 고수준에 언어들에만 사용할 수 있게 만들어져 있다. 

 

바이트코드 VM에서는 이런 툴은 무용지물이다. VM내에 브레이크 포인터를 걸고 들여다볼 수는 있지만, VM 자체가 무엇을 하는지 알 수 있을 뿐, VM이 바이트 코드를 어디까지 해석해 실행 중인지는 알 수 없다

 

정의하려는 행동이 단순하면 툴 없이도 어떻게든 해나갈 수 있다. 하지만 규모가 커진다면 바이트코드가 뭐 하는지를 확인할 수 있는 기능을 개발하는 데에 시간을 투자해야 한다. 이런 기능은 게임에 포함하여 출시하지는 않지만, 게임 출시에 중요한 역할을 하는 것은 분명하다.


예제 코드

마법의 API

마법 주문을 C++ 코드로 구현하려면 어떤 API가 필요할까? 그리고 마법을 정의할 때 게임에서 필요한 기본 연산에는 어떤 게 있을까?

 

마법은 대개 마법사의 스탯 중 하나를 바꾼다. 

void setHealth(int wizard, int amount);
void setWidom(int wizard, int amount);
void setAgility(int wizard, int amount);

wizard는 마법을 적용할 대상이다. 0이면 내 마법사, 1이면 상대방 마법사다. 회복 마법으로 내 마법사의 체력은 회복하고 상대방은 깎을 수 있다. 위 간단한 세 가지 함수만으로도 다양한 마법 효과를 만들 수 있다.

 

하지만 뭔가 눈에 보이는 게 없어 심심할 테니 좀 더 추가하자.

void playSound(int soundId);
void spawnParticles(int particleType);

사운드를 재생하고 파티클을 보여주는 함수는 게임 플레이에는 영향을 미치지 않지만 긴장감을 높여준다. 

 

마법 명령어 집합

이 API가 데이터에서 제어 가능한 뭔가로 어떻게 바뀌는지를 보자. 우선 매개변수를 제거한다. set___()과 같은 함수는 마법사의 스탯을 항상 최댓값으로 만든다. 이펙트 효과 역시 하드 코딩된 한 줄짜리 사운드와 파티클 이펙트만 보여준다.

 

이제 마법은 단순한 명령어 집합이 된다. 명령어는 각각 어떤 작업을 하려는지를 나타낸다. 명령어들을 다음과 같이 열거형으로 표현할 수 있다.

enum Instruction
{
    INST_SET_HEALTH = 0x00,
    INST_SET_WISDOM = 0x01,
    INST_SET_AGILITY = 0x02,
    INST_PLAY_SOUND = 0x03,
    INST_SPAWN_PARTICLES = 0x04
};

마법을 데이터로 인코딩하려면 열거형 값을 배열에 저장하면 된다. 원시 명령어 몇 개 없다 보니 한 바이트로 전체 열거형 값을 다 표현할 수 있다. 마법을 만들기 위한 코드가 실제로는 바이트들의 목록이다 보니 '바이트코드'라고 불린다.

 

명령 하나를 실행하려면 어떤 윈 시명령인지를 보고 이에 맞는 API 메서드를 호출하면 된다.

switch (instruction)
{
case INST_SET_HEALTH:
	setHealth(0, 100);
	break;

case INST_SET_WISDOM:
	setWisdom(0, 100);
	break;

case INST_SET_AGILITY:
	setAgility(0, 100);
	break;

case INST_PLAY_SOUND:
	playSound(SOUND_BANG);
	break;

case INST_SPAWN_PARTICLES:
	spawnParticles(PARTICLE_FLAME);
	break;
}

 

이런 식으로 인터프리터는 코드와 데이터를 연결한다. 마법 전체를 실행하는 VM에서는 이 코드를 다음과 같이 래핑 한다.

class VM
{
public:
	void interpret(char bytecode[], int size)
	{
		for (int i = 0; i < size; i++)
		{
			char instruction = bytecode[i];
			switch (instruction)
			{
				// 각 명령어들...
			}
		}
	}
};

여기까지 입력하면 첫 번째 가상 머신 구현이 끝났다. 하지만 이 가상 머신은 전혀 유연하지 않다. 상대방 마법사를 건드리거나 스탯을 낮추는 마법도 만들 수 없다. 사운드도 하나만 출력 가능하다. 

 

실제 언어와 같은 표현력을 갖추기 위해서는 매개변수를 받을 수 있어야 한다.

 

스택 머신

속도를 높이기 위해서 명령어를 1차원으로 나열해도 하위 표현식 결과를 중첩 순서에 맞게 다음 표현식에 전달해야 한다. 이를 위해 스택을 이용해서 명령어 실행 순서를 제어한다.

class VM
{
public:
	VM()
		: stackSize_(0)
	{}

	// 그 외...

private:
	static const int MAX_STACK = 128;
	int stackSize_;
	int stack_[MAX_STACK];
};

VM 클래스에는 값 스택이 들어가 있다. 명령어들은 이 스택을 통해 데이터를 주고받는다. 스택에 값을 넣고 뺄 수 있도록 두 메서드를 추가하자.

class VM
{
private:
	void push(int value)
	{
		// 스택 오버플로 검사
		assert(stackSize_ < MAX_STACK);
		stack_[stackSize_++] = value;
	}

	int pop()
	{
		// 스택이 비어 있지 않은지 확인
		assert(stackSize_ > 0);
		return stack_[--stackSize_];
	}

	// 그 외...
};

명령어가 매개변수를 받을 때는 다음과 같이 스택에서 꺼내온다.

switch (instruction)
{
case INST_SET_HEALTH:
{
	int amount = pop();
	int wizard = pop();
	setHealth(wizard, amount);
	break;
}

case INST_SET_WISDOM:
case INST_SET_AGILITY:
	// 위와 같은 식으로...

case INST_PLAY_SOUND:
	playSound(pop());
	break;

case INST_SPAWN_PARTICLES:
	spawnParticles(pop());
	break;
}

스택에서 값을 얻어오려면 리터럴 명령어가 필요하다. 리터럴 명령어는 정수 값을 나타낸다. 하지만 리터럴 명령어는 자신의 값을 어디에서 얻어올까? 

 

명령어 목록이 바이트의 나열이라는 점을 활용해, 숫자를 바이트 배열에 직접 집어넣으면 된다. 숫자 리터럴을 위한 명령어 타입은 다음과 같이 정의한다.

case INST_LITERAL:
{
	// 바이트코드에서 다음 바이트 값을 읽는다
	int value = bytecode[++i];
	push(value);
	break;
}

바이트코드 스트림에서 옆에 있는 바이트를 숫자로 읽어 스택에 집어넣는다.

 

인터프리터가 명령어 몇 개를 실행하는 과정을 보면서 스택 작동 원리를 이해해보자. 먼저 스택이 비어 있는 상태에서 인터프리터가 첫 번째 명령어를 실행한다.

 

먼저 INST_LITERAL부터 실행한다. 이 명령은 자신의 바이트코드 바로 옆 바이트 값(0)을 읽어서 스택에 넣는다.

 

두 번째 INST_LITERAL을 실행한다. 10을 읽어서 스택에 넣는다.

 

마지막으로 INST_SET_HEALTH를 실행한다. 스택에서 10을 꺼내와 amount 매개변수로 넣고, 두 번째로 0을 스택에서 꺼내 wizard 매개변수에 넣어 setHealth 함수를 호출한다.

 

우리 편 마법사의 체력을 10으로 만들어주는 마법을 만들었다. 이제 어느 편 마법사든 스탯을 마음대로 바꿀 수 있는 유연함을 갖췄다. 다른 사운드나 파티클도 출력할 수 있다.

 

하지만 여전히 코드보다는 데이터 같아 보인다. 예를 들어 체력을 지혜 스탯의 반만큼 회복하는 식으로 만들 수 없다. 기획자는 보통 숫자만이 아니라 규칙으로 마법을 표현할 수 있기를 원한다.

 

행동 = 조합

지금까지 만든 VM을 프로그래밍 언어로 본다면, 아직 몇 가지 내장 함수와 상수 매개 변수만 지원할 뿐이다. 좀 더 행동을 표현할 수 있게 하려면 조합을 할 수 있어야 한다. 

 

기획자는 여러 값을 이리저리 재미있게 조합하는 표현식을 만들고 싶어 한다. 예를 들면 정해진 값이 아니라 지정한 값으로 스탯을 바꿀 수 있는 마법을 만드는 식이다. 이렇게 하려면 현재 스탯을 고려해야 한다. 스탯을 바꾸는 명령어는 있으니, 스탯을 얻어오는 명령어를 추가하자.

case INST_GET_HEALTH:
{
	int wizard = pop();
	push(getHealth(wizard));
	break;
}

case INST_GET_WISDOM:
case INST_GET_AGILITY:
	// 뭘 하려는지 알것이다...

이 명령어는 스택에 값을 뺐다 넣었다 한다. 스택에서 매개변수를 꺼내 어느 마법사의 스탯을 볼 지 확인하고, 그 스탯을 읽어와 다시 스택에 넣는다. 이제 스탯을 복사하는 마법을 만들 수 있다. 

 

전보다는 낫지만 아직 부족하다. 다음은 계산 능력이 필요하다. 명령어를 좀 더 추가해보자. 덧셈만 살펴보자.

case INST_ADD:
{
	int b = pop();
	int a = pop();
	push(a + b);
	break;
}

다른 명령어들처럼, 덧셈도 값 두 개를 스택에서 뺀 다음 작업한 결과를 스택에 집어넣는다. 이제는 복잡하고, 여러 단계로 중첩된 수식 표현식도 뭐든지 처리할 수 있다.

 

복잡한 예레를 따라가 보자. 우리 편 마법사 체력을 민첩성과 지혜의 평균만큼 더해주는 마법을 만들 것이다. 코드로 보면 다음과 같다.

setHealth(0, getHealth(0) + (getAgility(0) + getWisdom(0)) / 2);

표현식에 들어 있는 괄호를 명시적으로 묶어줄 명령어가 따로 필요하다고 생각할지 모르겠지만, 스택을 사용하면 암시적으로 괄호를 처리할 수 있다. 계산 과정은 다음과 같다.

 

  1. 마법사의 현재 체력을 가져와 기억

  2. 마법사의 민첩성을 가져와 기억

  3. 마법사의 지혜를 가져와 기억

  4. 민첩성과 지혜를 가져와 더한 뒤에 그 결과를 기억

  5. 결과를 2로 나눈 뒤, 그 결과를 기억

  6. 마법사의 체력을 떠올린 뒤 이를 결과에 더함

  7. 결과를 가져와 마법사의 체력으로 만듦

여기서 '기억'은 스택에 push()로, '가져와'는 pop()으로 바꾸면 쉽게 바이트코드로 변환할 수 있다. 예를 들어 1번 과정은 다음과 같이 변환된다.

LITERAL 0
GET_HEALTH

이 바이트코드는 우리 마법사의 체력을 스택에 넣는다. 나머지 문장도 이런 식으로 기계적으로 변환하면 원했던 표현식을 담은 바이트코드를 얻을 수 있다. 바이트코드가 어떻게 생성되는지 감을 잡을 수 있도록 전체 변환 결과를 살펴보자.

 

스택 상태가 변하는 걸 보여주기 위해 마법사의 스탯이 체력 45, 민첩성 7, 지혜 11이라고 가정하고 예제를 실행해보자. 명령어 옆의 []는 명령어 실행 후의 스택 상태를 나타낸다. #는 주석으로 표시했다.

LITERAL 0    [0]            # 마법사 인덱스
LITERAL 0    [0, 0]         # 마법사 인덱스
GET_HEALTH   [0, 45]        # getHealth()
LITERAL 0    [0, 45, 0]     # 마법사 인덱스
GET_AGILITY  [0, 45, 7]     # getAgility()
LITERAL 0    [0, 45, 7, 0]  # 마법사 인덱스
GET_WISDOM   [0, 45, 7, 11] # getWisdom()
ADD          [0, 45, 18]    # 민첩 + 지혜
LITERAL 2    [0, 45, 18, 2] # 나누는 수
DIVIDE       [0, 45, 9]     # 민첩성과 지혜의 평균을 냄
ADD          [0, 54]        # 평균을 현재 체력에 더함
SET_HEALTH   []             # 결과를 체력으로 만듦

데이터가 스택을 통해 왔다 갔다 하는 걸 볼 수 있다. 

 

가상 머신

이런 식으로 계속 명령어를 추가해볼 수 있겠지만 이 정도만 한다. '바이트코드'나 '가상 머신'은 위협적으로 들리지만, 스택, 반복문, 다중 선택문만으로도 간단하게 만들 수 있다.

 

VM 구현 과정을 통해 '행동을 안전하게 격리한다'는 원래 목표를 달성했음을 확인했다. 바이트코드에서는 정의해놓은 명령 몇 개를 통해서만 다른 코드에 접근할 수 있기 때문에 악의적인 코드를 실행하거나 잘못된 위치에 접근할 방법이 없다.

 

스택 크기를 통해 VM의 메모리 사용량을 조절할 수 있다. 스택이 VM에서 오버플로 하지 않는지도 검사하고 있다. VM이 시간을 얼마나 쓸지도 제어할 수 있다. 그리고 반복문에서 실행되는 명령어가 일정 개수 이상이면 빠져나오게 할 수도 있다.

 

마법 제작 툴

원래 목표는 행동을 고수준으로 제작할 수 있는 방법을 만드는 것이었지만, 지금까지는 더 저수준 시스템을 만들었을 뿐이다. 성능이나 안정성은 만족스럽지만, 기획자가 건드릴 수 있는 물건은 아니다.

 

이런 차이를 극복하기 위해 사용성을 좋게 할 이 필요하다. 툴을 이용하여 마법에 대한 행동을 고수준으로 정의하고, 이를 저수준인 바이트코드로 변환할 수 있어야 한다.

 

특히 툴 사용자가 프로그래밍에 익숙하지 않다면 그래픽 인터페이스로 행동을 정의할 수 있는 툴을 고려해보자. 문법 오류가 생기기 쉬운 텍스트 형식으로 만드는 것은 쉬운 일이 아니다.

 

대신 클릭해서 작은 상자를 드래그 앤 드롭하거나 메뉴를 선택하는 식으로 행동을 조립할 수 있는 툴을 만들어보자.

 

행동제작을 위한 GUI예

GUI 툴에서는 사용자가 '잘못된' 코드를 아예 만들 수 없다. 오류를 토해내기보다 아예 버튼을 못 누르게 하거나 기본값을 넣으면 제작하는 동안 오류 없는 상태를 유지할 수 있다. 이러면 언어를 위한 문법이나 파서(Parser)를 만들지 않아도 된다. 

 

바이트코드 패턴의 궁극적인 목표사용자가 행동을 고수준 형식으로 편하게 표현할 수 있도록 하는 데 있다. 먼저 사용성을 좋게 만드는 데에 공을 들여야 한다. 그다음으로 코드 실행을 최적화하기 위해, 이를 저수준 형태로 변환해야 한다. 보통 일은 아니지만 만들기만 한다면 노력에 걸맞은 결실을 얻을 수 있다.


자료의 출처는 http://gameprogrammingpatterns.com/bytecode.html 입니다.

반응형
Comments