Bytecode Pattern(바이트코드 패턴)

2024. 6. 24. 10:06Game Develop/Design Pattern

 

바이트코드 패턴은 가상 머신 명령어를 요즘 코딩한 데이터로 행동을 표현할 수 있는 유연함을 제공한다.

 

요즘 게임을 만들려면 엄청난 양의 복잡한 소스 코드를 구현해야 한다.

 

크래시가 나지 않게 구현도 해야하고 동시에 플랫폼의 성능도 최대한 끄집어내야 한다. 게임은 다른 모든 소프트웨어보다 성능이 중요하다. 또한 게임 시장 경쟁력에서 뒤쳐지지 않게 최적화도 지속해야한다.

 

성능과 안정성을 위해 C++ 같은 중량언어를 사용한다. 이런 언어는 성능을 최대한 끌어낼 수 있는 저수준 표현과 버그를 막거나 적어도 가둬두기 위한 풍부한 타입 시스템을 함께 제공한다.

 

게임에는 골치 아프면서도 가장 중요한 제약이 하나 더 있는데 그것은 바로 재미다. 유저들은 신선하면서도 배런스가 잘 맞는 게임을 원한다. 이런 게임 개발을 위해선 반복 개발을 계속해야 하는데, 코드가 비효율적이고 테스트를 위해 빌드를 시도할 때마다 오래 걸린다면 창조적인 상태에 있긴 어렵다.

 

 

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

 

마법이 하드코딩이라도 돼있다면 마법을 바꿀 때마다 게임 실행 파일을 패치해야 한다.

더 나아가 모드를 지원해야 한다면? 유저가 자신만의 마법을 만들 수 있게 하고 싶다면 더 복잡해진다. 모드 개발자는 빌드를 위해 컴파일러 툴체인을 전부 갖춰야 하고, 개발사는 소스를 공개해야 한다. 또 이렇게 탄생한 마법에 버그라도 있다면 다른 플레이어들과 서로 충돌하여 게임이 갑자기 다운되는 사태까지 발생할 수 있다.

 

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

 

만약 이 행동을 데이터 파일에 따로 정의해놓고 게임 코드에서 읽어서 실행할 수만 있다면, 위의 문제를 해결할 수 있다.

 

데이터를 실행한다는 것은 어떤 의미인가? 파일에 있는 바이트로 행동을 어떻게 표현할까? 이에는 몇가지 방법이 있다.

GOF의 인터프리터 패턴과의 비교를 통해 이 패턴의 장단점을 이해해보자.

 

 

인터프리터 패턴은 GoF의 세가지 패턴 중 행위 패턴에 속한다.

 

행위 패턴은 클래스나 객체들이 서로 상호작용하는 방법이나 책임 분배 방법을 정의하는 패턴이다.

 

인터프리터 패턴의 특징은 다음과 같다.

- 언어에 문법 표현을 정의하는 패턴

- SQL이나 통신 프로토콜과 같은 것을 개발할 때 사용

 

실행하고 싶은 프로그래밍 언어가 있다고 해보자. 이 언어는 다음과 같은 수식을 지원한다.

(1 + 2) * (3 - 4)

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

 

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

 

인터 프리터 패턴의 목적은 이런 추상 구문 트리를 만드는 데에 끝나지 않고 이를실행 하는데 의미가 있다.

표현식 혹은 하위표현식 객체로 트리를 만든 뒤, 진짜 객체지향 방식으로 표현식이 자기자신을 평가하게 한다.

 

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

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) {}
    
    virtual double evaluate(){
        // 피연산자를 실행한다.
        double left = left_->evaluate();
        double right = right_->evaluate();
        
        // 피연산자를 더한다.
        return left + right;
    }

private:
    Expression* left_;
    Expression* right_;
};

 

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

 

인터프리터 패턴도 몇 가지 단점이 있다. 많은 상자와 그 상자 사이를 잇는 가지가 보인다. 복잡한 프랙탈 트리 모양으로 코드가 표현되기 때문에 다음과 같은 단점이 있다.

  • 코드를 로딩하며 작은 객체를 엄청 많이 만들고 연결해야 한다.
  • 이들 객체와 객체를 잇는 포인터는 많은 메모리를 소모한다. 앞에서 봤던 간단한 수식을 표현하는 데에도 32비트 CPU에서 최소 68바이트가 필요하다.
  • 포인터를 따라 하위표현식에 접근하기 때문에 캐시에 치명적이다. 동시에 가상 메서드를 호출하는 것은 명령어 캐시에 치명적이다.

위의 특징을 다 종합해보면, 나올 수 있는 결론은 하나다. 느리다. 느릴 뿐더러 메모리도 많이 필요하기 때문에 널리 쓰이는 대부분의 프로그래밍 언어는 인터프리터 패턴을 쓰지 않는다.


 

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

  • 밀도가 높다. 바이너리 데이터가 연속해서 꽉 차있어서 한 비트도 낭비하지 않는다.
  • 선형적이다. 명령어가 같이 모여 있고 순서대로 실행된다.
  • 저수준이다. 각 명령어는 비교적 최소한의 작업만 한다. 이를 결합함으로써 흥미로운 행동을 만들 수 있다.
  • 빠르다. 앞에서 본 이유로 (게다가 하드웨어로 직접 구현되어 있어서) 속도가 굉장히 빠르다.

위의 장점들이 있다고 하더라도 마법을 진짜 기계어로 구현하고 싶은 생각이 드는가? 만약 이렇게 한다면 해커들이 못잡아 먹어서 안달이 나는 꼴을 경험할 수 있다. 따라서 기계어의 성능과 인터프리터 패턴의 안정성 사이에서 타협을 봐야한다.

 

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

 

이 에뮬레이터를 가상 머신(Virtual Machine, VM)이라 부르고, VM이 실행하는 가상 바이너리 기계어는 바이트코드라고 부른다. 바이트코드는 유연하고, 데이터로 여러 가지를 쉽게 정의할 수 있으며, 인터프리터 패턴 같은 고수준 형식보다 성능도 우수하다.

 

기능을 제한하기만 한다면 바이트코드 패턴을 구현해보는 것도 해볼만 할 것이다.

 

 

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

 

바이트코드 패턴은 여타 다른 패턴 중에서도 복잡하고 쉽게 적용 할수도 없는 패턴이다. 정의할 행동은 많은데 다음과 같은 이유로 게임 구현에 사용한 언어로는 한계가 있을때 바이트코드 패턴을 사용한다.

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

물론 대부분의 게임이 이에 해당한다. 바이트코드는 네이티브 코드보다는 느리므로 성능이 민감한 곳에는 적합하지 않다.

 

바이너리 바이트코드 형식은 사용자가 작성할 만한 게 아니다. 행동 구현을 코드에서 따로 빼낸 이유는 고수준으로 표현하기 위함이다. C++이 너무 저수준이라면서 사용자에게 어셈블리 언어, 그것도 우리가 만든 어셈블리어로 행동을 구현하게 하는 건 어불성설이다.

 

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

 

 

프로그래밍은 어렵다. 기계에 어떤 일을 시키고 싶지만 이를 정확하게 표현하지는 못하다 보니 버그가 발생한다. 다행히도 이런 버그를 찾아 고치기 위해 코드가 무엇을 잘못하는지 어떻게 고쳐야 할지를 알아보는 데 도움이 되는 툴 또한 많다.

이런 디버거, 정적 분석기, 디컴파일러 같은 툴은 기성 언어만 사용할 수 있게 만들어졌다.

 

자신이 만든 바이트코드 VM에는 이런 툴이 무용지물이다. 디버거에서 VM에 브레이크포인터를 걸고 들여다볼 수는 있지만, VM 그 자체가 무엇을 하는지 알 수 있을 뿐 VM이 바이트코드를 어디까지 해석해 실행 중인지는 알 수 없다. 이런 식으로는 바이트코드가 컴파일되기 전인 고수준 형태를 추적할 수 없다.

 

디버깅 툴 없이도 단순한 행동을 정의하는 건 어떻게 하다보면 해나갈 수 있다. 하지만 콘텐츠 규모가 커지면 커질수록 바이트코드가 뭐 하는지 확인할 수 있는 기능을 개발하는 데 시간을 투자해야한다. 이런 기능 또한 게임에 포함된 것은 아니지만, 게임을 개발하는 데에는 굉장히 중요한 역할을 한다.

 

 

자 이제 본격적으로 바이트 코드 구현을 해보자. 먼저 VM에 필요한 명령어 집합을 정의하자. 바이트코드니 뭐니 자세히 들어가기 전 우리가 만들려는게 API 같은 거라고 생각해보자.

 

마법 주문을 C++ 코드로 구현하려면 어떤 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,
};

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

 

명령 하나를 실행하려면 어떤 원시명령인지를 보고 이에 맞는 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){
                // 각 명령별로 case문이 들어간다.
            }
        }
    }
};

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

 

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

 

 

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

 

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

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

priavte:
    static const int MX_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_];
    }
    // 그 외.....
};

 

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

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 함수를 호출한다.

 

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

 

 

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

 

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

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

case INST_+GET_WISDOM;
CASE INST_GET_AGILITY;

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

 

전보다는 낫지만 아직 부족하다. 다음으로는 계산 능력이 필요하다. 아기 VM에게 1 + 1을 가르쳐줄 때다. 명령어를 좀 더 추가해보자. 이쯤 되면 어떻게 만들어야 할지 감을 잡았을 것이다. 덧셈만 살펴보자.

 

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

다른 명령어들처럼, 덧셈도 값 두개를 스택에서 뺀 다음 작업한 결과를 스택에 집어 넣는다. 이전에도 명령어를 추가할 때마다 표현력이 조금씩 좋아졌지만, 이번에는 확 좋아졌다. 아직은 눈에 잘 들어오지 않겠지만 이제는 복잡하고, 여러 단계로 중첩된 수식 표현식도 뭐든지 처리할 수 있다.