소년코딩

바이트코드 패턴, Bytecode Pattern

가상 머신 명령어를 인코딩한 데이터로 행동을 표현할 수 있는 유연함을 제공한다.

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

데이터 > 코드

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

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

데이터를 '실행'한다는 의미를 먼저 짚고 넘어가자. 파일에 잇는 바이트로 행동을 어떻게 표현할 수 있을까? 몇 가지 방법이 있다. 인터프리터 패턴과의 비교를 통해 이 패턴의 장단점을 이해해보자.

인터프리터 패턴

(1 + 2) * (3 - 4)

이런 표현식을 읽어서 언어 문법에 따라 각각 객체로 벼노한해야 한다. 숫자 리터럴은 다음과 같이 각기 객체가 된다. 

bytecode-numbers

숫자 상수는 단순히 숫자 값을 래핑한 객체다. 연산자도 객체로 바뀌는데, 이때 피연산자도 같이 참조한다. 괄호와 우선순위까지 고려하게 되면 표현신이 다음과 같이 작은 객체 트리로 바뀐다.

bytecode-ast

인터프리터 패턴의 목적은 이런 추상 구문 트리를 만드는 데에서 끝나지 않고 이를 실행하는 데 있다. 표현식 혹은 하위표현식 객체로 트리를 만든 뒤에, 진짜 객체지향 방식으로 표현식이 자기 자신을 평하가헤 한다.

먼저 모든 표현식 객체가 상속받을 상취 인터페이스를 만든다.

class Expression {
public:
    virtual ~Expression() {}
    virtual double evaluate() = 0;
};

언어 문법에서 지원하는 모든 표현식마다 Expression 인터페이스를 상속받는 클래스를 정의한다. 숫자가 가장 간단하므로 숫자부터 보자.

class NumberExpression : public Expression {
public:
    NumberExpression(double value) : value_(value) {}
    virtual double evaluate() { return value_; }

private:
    double value_;
};

숫자 리터럴 표현식은 단순히 자기 값을 평가한다. 덧셈, 곱셈에는 하위표현식이 들어 있기 때문에 좀 더 복잡하다. 이런 표현식은 자기를 평가하기 전에 먼저 포함된 하위표현식을 재귀적으로 평가한다.

class AdditionExpression : public Expression {
public:
    AdditionExpression(Expression* left, Expression* right)
    : left_(left), right_(right) {}

    virtaul double evaluate() {
        double left = left_->evaluate();
        double right = right_->evaluate();

        return left + right;
    }
private:
    Expression* left_;
    Expression* right_;
};

간단한 클래스 몇 개만으로 어떤 복잡한 수식 표현도 마음껏 나타내고 평가할 수 있다. 필요한 만큼 객체를 더 만들어 원하는 곳에 적절히 연결하기만 하면 된다.

인터프리터 패턴은 단순한 패턴이지만 문제도 좀 있다.

  • 코드를 로딩하면서 작은 객체를 엄청 많이 만들고 연결해야 한다.
  • 이들 객체와 객체를 잇는 포인터는 많은 메모리를 소모한다.
  • 포인터를 따라서 하위표현식에 접근해야 하기 때문에 데이터 캐시에 치명적이다. 동시에 가상 메서드를 호출하는 것은 명령어 캐시에 치명적이다.

결론은 너무 느린 데다가 메모리가 많이 필요하기 때문에 널리 쓰이는 대부분의 프로그래밍 언어는 인터프리터 패턴을 쓰지 않는다.

가상 기계어

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

기계어는 다음과 같은 장점이 있다.

  • 밀도가 높다. 바이너리 데이터가 연속해서 꽉 차 있어서 한 비트도 낭비하지 않는다.
  • 선형적이다. 명령어가 같이 모여 있고 순서대로 실행된다. (흐름 제어문을 실행하는 경우를 제외하면) 메모리를 넘나들지 않는다.
  • 저수준이다. 각 명령어는 비교적 최소한의 작업만 한다.
  • 빠르다. 앞에서 본 이유로 (게다가 하드웨어로 직접 구현되어 있어서) 속도가 굉장히 빠르다.

이런 장점이 좋아 보이긴 해도 게임에서 실행되는 기계어를 유저에게 제공하는 것은 매우 좋은 선택이 아니다.

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

이 에뮬레이터를 가상 머신 (VM)이라 부르고, VM이 실행하는 가상 바이너리 기계어는 바이트코드라고 부르겠다.


패턴

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


언제 쓸 것인가?

  • 언어가 너무 저수준이라 만드는 데 손이 많이 가거나 오류가 생기기 쉽다.
  • 컴파일 시간이나 다른 빌드 환경 때문에 반복 개발하기가 너무 오래 걸린다.
  • 보안에 취약하다. 정의하려는 행동이 게임을 깨먹지 않게 하고 싶다면 나머지 코드로부터 격리해야 한다.

그러나 바이트코드는 네이티브 코드보다는 느리므로 성능이 민감한 곳에는 적합하지 않다.


예제 코드

앞으로 살펴볼 바이트코드 구현 예제를 보면 생각보다 구현이 쉽다고 느낄 수도 있다. 먼저 VM에 필요한 명령어 집합을 정의하자. 바이트코드니 뭐니 자세히 들어가기 전에 만들려는 게 API 같은 거라고 생각해보자.

마법의 API

마법은 대게 마법사의 스탯 중 하나를 바꾼다. 그렇다면 이런 API부터 시작해보자.

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

첫 번째 매개변수(wizard)는 마법을 적용할 대상이다. 마법이 조용히 스탯만 바꾼다면 게임 로직은 괜찮아도 플레이어는 심심할 테니 뭔가 좀 더 추가하자.

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,
};

bytecode-code

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

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

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

    // ... more

    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(instrction) {
                // 각 명령별로 case문이 들어간다.
            }
        }
    }
};

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

실제 언어와 같은 표현력을 가주려면 매개변수를 받을 수 있어야 한다.

스택 머신

복잡한 중첩식을 실행하려면 가장 안쪽 하위표현식부터 계산해, 그 결과를 이를 담고 있던 표현식의 인수로 넘긴다. 이걸 전체 표현식이 다 계산될 떄까지 반복하면 된다.

인터프리터 패턴에서는 중첩 객체 트리 형태로 중척십을 직접 표현했다. 속도를 높이기 위해 명령어를 1차원으로 나열해도 하위표현식 결과를 중첩 순서에 맞게 당므 표현식에 전달해야 한다. 이를 위해 CPU처럼 스택을 이용해서 명령어 실행 순서를 제어한다.

class VM {
public:
    VM() : stackSize_(0) {}
    // More...

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

vm 클래스에는 값 스택이 들어 있다. 예제 코드에서는 명령어가 숫자 값만 받을 수 있기 때문에 그냥 int 배열로 만들었어. 명령어들은 이 스택을 통해서 데이터를 주고 받는다. 이름에 맞게 스택에 값을 넣고 뺄 수 있도록 메서드를 추가하자.

class VM {
private:
    void push(int value) {
        assert(stackSize_ < MAX_STACK);
        stack_[stackSize_++] = value;
    }

    int pop() {
        assert(stackSize_ > 0);
        return stack_[--stackSize_];
    }

    // More...
};

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

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;
}

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

bytecode-literal

인터프리터가 명령어 몇 개를 실행하는 과정을 보면서 스택 작동 원리를 이해해보자.

먼저 스택이 비어 있는 상태에서 인터프리터가 첫 번째 명령을 실행한다.

bytecode-stack1

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

bytecode-stack2

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

bytecode-stack3

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

이제 어느 편 마법사든 스탯을 마음대로 바꿀 수 있는 유연함을 갖췄다. 다른 사운드나 파티클도 출력할 수 있다.

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

행동 = 조합

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

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

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

case INST_GET_WISDOM:
case INST_GET_AGILITY:
    // More...

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

전보다는 낫지만 아직 부족하다. 다음으로는 계산 능력이 필요하다. VM에게 1+1을 가르쳐줄 때다. 명령어를 좀 더 추가해보자.

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

다른 명령어들처럼, 덧셈도 값 두 개를 스택에서 뺀 다음 작업한 결과를 스택에 집어넣는다.

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

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

위에서 1번 '현재 체력 가져오기'는 다음과 같이 변환된다.

LITERAL 0
GET_HEALTH

이 바이트코드는 마법사의 체력을 스택에 넣는다.

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

LITERAL 0          [0]           # 마법사 인덱스
LITERAL 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         []            # 결과를 체력으로 만듬

가장 먼저 마법사 인덱스용으로 스택에 넣은 0은 스택 밑바닥에 남아 있다가 끝에서 SET_HEALTH 명령을 실행할 때 사용된다.

가상 머신

이런 식으로 계속 명령어를 추가해볼 수 있지만 이 정도만 하자. 지금까지 만든 VM 만으로도 단순하면서도 깔끔한 데이터 형태로 행동을 마음껏 정의할 수 있다. '바이트코드'나 '가상머신'은 위협적으로 들리지만, 방금 본 것처럼 스택, 반복문, 다중 선택문만으로도 간단하게 만들 수 있다.

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

그러나 지금까지 사람 손으로 의사코드를 바이트코드로 컴파일 했다. 시간이 남아돈다면 모를까, 이러 방식은 현실성이 없다.

마법 제작 툴

위의 시스템은 런타임 성능이나 안정성은 만족스럽지만, 기획자가 건드릴 만한 물건은 전혀 아니다.

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

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

bytecode-ui


이 포스트의 글과 그림의 출처는 http://gameprogrammingpatterns.com/bytecode.html 입니다.


디자인패턴

by 소년코딩

추천은 글쓴이에게 큰 도움이 됩니다.

악플보다 무서운 무플, 댓글은 블로그 운영에 큰 힘이됩니다.

댓글 로드 중…

블로그 정보

소년코딩 - 소년코딩

소년코딩, 자바스크립트, C++, 물리, 게임 코딩 이야기

최근에 게시된 이야기