ECS Model

ECS는 Entity, Component, System의 약자로, 이 세가지의 요소로 설계하는 아키텍쳐 패턴이다. 몇몇 상용 엔진에서 잘 사용하고 있는 컴포넌트 모델Component Model과도 다르고, 90년대 후반과 2000년대 초반에 주로 쓰였던 고전적인 액터 모델Actor Model과도 확연히 다르다.

액터 모델

액터모델은 ‘모든 것은 객체다’라는 OOP의 기본 개념과 비슷하게 ‘모든 것은 액터다’라는 기본 개념을 토대로 만들어진 모델이다. 액터 모델이 등장한 배경엔 멀티쓰레드가 있다.

Heap이 여러 개의 쓰레드에게 공유되고 Java와 C#에서 객체라는 메모리 공간이 기본적으로 변경가능Mutable한 이상 완벽하게 안전한 멀티쓰레드 코드를 작성하는 것은 가능하지 않다. 변경가능성Mutability과 자원의 공유Sharing는 죽음의 칵테일이다.

액터는 쓰레드 혹은 객체와 구별되는 추상적인 개념이다. 액터가 차지하는 메모리 공간은 어느 다른 쓰레드 혹은 액터가 접근할 수 없다. 다시 말해서 액터 내부에서 일어나는 일은 어느 누구와도 공유되지 않는다. 앞서 언급한 죽음의 칵테일에서 공유라는 속성을 제거함으로써 멀티쓰레드와 관련된 문제의 대부분을 제거했다.

각 액터들은 서로의 주소Mail Address를 통해 서로 메세지를 주고 받는 것으로 주어진 일을 동시에 수행한다. 단지 메시지만을 주고 받고 상태를 공유하지 않기 때문에 액터 내부에서 작업을 수행할 때는 lock이나 synchronized와 같은 부자연스러운 키워드가 필요 없다. 그래서 액터 모델에서는 잠금장치나 쓰레드라는 개념이 눈에 보이지 않는 어디론가 사라진다.

ecs-1

'’메세지를 받으면 그에 맞는 로직을 실행한다’‘는 매우 간단한 동작원리와 다른 것에 영향을 받지 않는다는 특징 때문에 실행 순서를 이해하고 결과를 예측하기 매우 쉽다. 또한, 모든 간섭을 메세지를 통해서 한다는 것도 큰 장점이다. 하지만 반응성이 떨어지는 문제과 더불어, 비슷한 기능들을 제공하는 액터를 설계할 때 문제가 발생한다.

예를 들어 다음과 같은 액터를 구현한다고 해보자.

  조작 타기 공격 날기
플레이어 가능 불가능 가능 불가능
불가능 가능 불가능 불가능
고블린 불가능 불가능 가능 불가능
드래곤 불가능 가능 가능 가능

어떤 식으로 구현을 해야할까? 모든 기능별로 메소드를 만들고 상속을 한다면 중복되는 구현이 너무 많다. 기능이 확장되는 방향으로 상속을 한다면 드래곤이 말을 상속 받고 플레이어가 고블린을 상속해야한다. 뭔가 이상하다.

컴포넌트 모델

컴포넌트 모델은 이런 상황을 해결해 줄 수 있다. 각 기능을 컴포넌트로 구현하고 각 행동에 필요한 컴포넌트만 추가해준다면 필요한 기능들을 수행하도록 할 수 있다. 또한 컴포넌트를 추가하는 게 아니라면 기존 컴포넌트들의 조합을 통해 새로운 오브젝트 또한 만들 수 있다. 구현의 중복을 피하고 코드의 재사용을 극대화 할 수 있다.

ecs-2

하지만 기능을 아무리 작은 단위로 쪼갠다고 하더라도, 하나의 기능이 수행될 때 두 개 이상의 컴포넌트가 필요한 경우 각 컴포넌트간의 간섭이 생길 수 있다. 액터 모델에선 하나로 묶어서 관리되던 상태 정보들이 컴포넌트라는 작은 단위로 나눠져 관리되기 때문이다. 이렇게 나눠진 상태 정보에 접근하려 할 때, 컴포넌트엔 상태 뿐만 아니라 로직도 함께 존재하기 때문에 주의하지 않으면 로직끼리 영향을 주게 된다.

이러한 로직간의 간섭은 실행 순서의 예측을 어렵게 한다. 메세지는 너무 느리기 때문에 보통 인터페이스를 통한 추상화를 통해 컴포넌트 간 통신을 하게 되는데, 추상화를 통한 확장을 위한 인터페이스가 오히려 기능을 제한하는 역할도 하기 때문에 그 원래의 목적을 잃은채로 사용하게 될 수 있다.

ECS Entity Component System

컴포넌트 모델이나 액터 모델의 경우 상태State와 로직Behavior을 동시에 갖고 있지만 ECS는 상태와 로직를 컴포넌트와 시스템, 이 두개의 요소를 통해 분리한다. 컴포넌트는 상태를 저장하고 로직이 없는 반면, 시스템은 로직은 있지만, 상태를 저장하지 않는다. 쉽게말해 컴포넌트는 Function이 없고, 시스템은 Field가 없다.

컴포넌트는 로직을 담고 있지않기 때문에 한 로직이 여러개의 컴포넌트의 상태 정보에 접근하더라도 시스템끼리 서로를 호출하지 않는 한 로직끼리는 영향을 받지 않는다. 때문에 로직과 데이터를 확실히 분리함으로써, ECS는 빠르게 변화하는 코드베이스Code Base에서 복잡성Complexity을 효과적으로 관리할 수 있다.

ECS는 다음과 같이 구성된다.

ecs-0

월드는 시스템과 엔티티의 집합체로 구성된다. 엔티티는 컴포넌트의 집합체에 해당하며 유니크한 아이디를 갖는다. 컴포넌트나 엔티티의 경우 로직이 없기 때문에, 게임의 모든 로직은 시스템을 통해서 수행된다. 게임의 한 프레임 또는 타임 틱Time Tick 마다 월드에 존재하는 시스템을 순회Iterate하며 로직을 실행한다. 여기서 시스템은 각 엔티티가 무엇인지 알 수 없다. 단지 로직이 수행되는데 필요로 하는 일부 컴포넌트에 관여하고, 그 일부 컴포넌트에 대해 같은 로직을 수행한다.

시스템의 로직을 수행하는데 하나 이상의 컴포넌트를 필요할 경우 컴포넌트는 한 엔티티에 같이 포함되는 컴포넌트에 접근할 수 있어야한다. 로직은 엔티티가 무엇인지 모르지만 컴포넌트가 저장하는 정보는 한 엔티티에 대한 정보이기 때문이다. 컴포넌트가 담고 있는 데이터는 결국 엔티티를 나타내는 정보가 된다.

Subject 관점에서의 ECS

앞서 말한 것처럼 컴포넌트가 담고 있는 데이터는 엔티티를 정의한다. 하지만 이것은 ECS만의 특징이 아니다. ECS보다 앞서 존재했던 액터 모델이나 컴포넌트 모델 또한 데이터를 통해 엔티티를 정의할 수 있다. 하지만 데이터와 로직이 분리되는 특징 때문에 ECS는 특별함을 갖는다.

한 대상Subject은 상황에 따라 다른 반응을 보인다. 한 대상의 관점에서 모든 상황에 대한 모든 로직을 구현하는 방법보다 각 상황의 상호작용에 대한 로직만 구현하는 편이 간단하다. 그리고 데이터와 로직의 분리는 이런 구현을 편리하게 만들어 줄 수 있다.

앞서 말한 로직 구현 방법의 설명으론 개발 시간이 단축 될 수 있을 것 같지만 익숙해지기 전까진 그렇지 않을 것이다. 일반적인 개발자들에게 익숙한 OODObject Oriented Design가 아닌 DODData Oriented Design의 개념이 적용되었기 때문이다.

그렇다면 일반적이지 않은 새로운 개발 방식을 도입하면서 까지 ECS의 설계 방식이 나오게 된 이유는 무엇일까? 그건 바로 성능이다.

데이터 기반 설계

ecs-3

일반적인 소프트웨어 엔지니어라면, 혹은 컴퓨터에 관심이 있다면 알고 있듯이 컴퓨터는 여러 종류의 저장소를 갖고 있다. 프로그램에서 사용되는 모든 데이터들은 이 저장소에 저장되며 사용되는데 저장소의 종류마다 성능이 다르다. 효율적인 연산을 위해 컴퓨터는 성능과 가격이 높은 저장소에 자주 사용되는 데이터를 캐시해두고 사용한다. 이런 컴퓨터의 특성 때문에 OOD와 DOD의 성능 격차가 발생하게 된다.

public class Player {
    public static final float CLOSE_DISTANCE = 0.5f;
    
    public String playerName;			// 20 byte
    public Vector3 position;			// 12 byte
    public Quaternion rotation; 		// 16 byte
    public Vector3 scale;			// 12 byte
    
    public Enemy closeEnemy;
    
    public Player findCloseEnemy(List<Enemy> enemyList) {
        for (int i = 0 ; i < enemyList.Count; i++) {
            if (Math.distance(this.position, enemyList.get(i).position) < CLOSE_DISTANCE) {
                closeEnemy = enemyList.get(i);
                break;
            }
        }
        
        closeEnemy = null;
    }
}

위의 코드는 일반적인 OOD를 통해 작성된 게임 오브젝트 클래스이다. 만약 월드에 Player 오브젝트가 10개 존재하고 월드에서 업데이트가 수행될 때 findClosePlayer() 메서드가 호출된다고 가정해보자. 먼저 각각의 Player의 findClosePlayer가 호출 될때 캐시 되어있는 Player가 아닌 새로운 Player에 접근하기 때문에 캐시 미스가 발생한다.

public class Player {
    public String playerName;			// 20 byte
    public Vector3 position;			// 12 byte
    public Quaternion rotation; 		// 16 byte
    public Vector3 scale;			// 12 byte
    
    public Enemy closeEnemy;
}

public class FindCloseEnemySystem {
    public void update() {
        List<Player> playerList = World.GetComponents<Player>();
        List<Enemy> enemyList = World.GetComponents<Enemy>();
        
        for (int i = 0; i < playerList.Count; i++) {
            for (int j = 0; i < enemyList.Count; j++) {
                if (Math.distance(playerList.get(i).position, enemyList.get(j).position) < CLOSE_DISTANCE) {
                	playerList.get(i).closeEnemy = enemyList.get(i);
                    continue;
            	}
            }
            playerList.get(i).closeEnemy = null;
        }
    }
}

DOD를 통해 Player을 구현하면 위의 코드와 같다. Player 클래스는 데이터를 담는 컴포넌트로 사용되고 실제 로직은 FindCloseEnemySystem이라는 System 클래스를 통해 수행된다. 같은 로직을 수행하지만 필요한 컴포넌트는 리스트 형태로 접근하고 순회하며 접근되기 때문에 한 게임 오브젝트에 대한 로직이 수행될때 마다 캐시 미스가 발생하지 않는다.

한 객체가 캐시에 로드될 때 캐시 메모리엔 그 객체의 모든 멤버 변수가 함께 로드된다. 하지만 위의 예에선 실제 로직에 사용되는 position 멤버 변수 외에도 사용되지 않는 변수들이 함께 로드되어 쓸데없이 메모리 공간을 점유한다. ECS에선 이 문제를 간단하게 해결할 수 있다.

public class Player {
    public String playerName;			// 20 byte
    public PositionComponent position;		// 12 byte
    public Quaternion rotation; 		// 16 byte
    public Vector3 scale;			// 12 byte
}

public class PositionComponent {
    public Vector3 position;			// 12 byte
}

public class FindCloseEnemySystem {
    public void update() {
        List<PositionComponent> playerPosList = World.GetComponents<PositionComponent>(Player.class);
        List<PositionComponent> enemyPosList = World.GetComponents<PositionComponent>(Enemy.class);
        ...

로직에서 필요한 데이터 크기로 컴포넌트로 나누면 불필요하게 점유되는 캐시 메모리를 없앨 수 있다. 캐시를 점유하는 메모리 사이즈를 줄이면 더 많은 수의 인스턴스가 캐시될 수 있고 이는 캐시 히팅 확률을 높힌다. 이처럼 DOD를 통한 설계는 효율적인 메모리 접근을 가능하게 하여 더 좋은 성능을 이끌어 낼 수 있다.

컴포넌트를 예시로 든 클래스가 아니라, C 계열 언어에서 지원하는 struct를 통해 구현할 경우, 배열안에 컴포넌트가 메모리에 순차적으로 존재되기 때문에 더 좋은 성능을 얻을 수 있다.

멀티 스레드와 ECS

일반적인 컴포넌트 모델 아키텍쳐 기반의 게임 엔진들은 메인 로직들을 메인이 되는 스레드에서 모두 처리하는 방식을 취한다.

ecs-4

위와같은 구조를 갖는 OOD 기반의 엔진에서 멀티스레드를 사용하는 것을 생각해보자. 오브젝트가 갖는 데이터들은 오브젝트 마다의 로직을 통해 변경되기 때문에 언제 어떻게 데이터가 변할지 알 수 없다. 한번에 한 스레드만 접근을 하도록 락을 거는 것도 가능하지만, 이것은 데이터를 안전하게 접근할 수 있도록 하지만 성능상의 이슈를 만들어낸다.

우리는 게임 엔진에 대해서 말하고 있다. 월드 안에 존재하는 모든 데이터들은 개발자가 설계하고 구현한 것들이다. 이럼에도 우리가 언제 어느 데이터가 어떻게 업데이트 되는지 확신하지 못하는 이유는 같은 종류의 데이터라도 업데이트가 오브젝트에 의해 수행되기 때문에 예측하기 어렵기 때문이다.

ECS는 데이터 기반 설계 모델이기 때문에 이러한 예측의 난이도를 낮춰줄 수 있다.

ecs-5

ECS의 업데이트는 게임 오브젝트가 아닌 System이라 불리는 로직을 담고 있는 특별한 클래스에 의해 수행된다. 각각의 로직은 이 System에서 Component를 순환하며 수행하는데, 이 때 우리는 어떤 System에서 어떤 Component를 사용하는지 알 수 있다. 때문에 컴포넌트 모델과 달리 ECS 모델에선 언제 어떤 데이터가 사용되는지에 대한 예측할 수 있고 이는 멀티 스레드 사용을 가능하게 한다.

ECS의 한계

ECS는 현재까지 가장 진보한 아키텍쳐 디자인임에 확실하다. 2018년 유니티에선 에셋으로만 존재하던 이 아키텍쳐 모델을 공식적으로 지원하기 시작했고, 실제로 이를 적용했을때 10배 정도의 성능을 보여주기도 하였다. 하지만 세대를 거듭하며 발전해온 모델들과 같이 ECS가 만능은 아니다.

ECS는 기본적으로 순회Iterating 로직에서 강점을 보인다. 그렇기 때문에 수 많은 오브젝트의 로직이 루프 안에서 업데이트 될 때 그 효과가 증폭된다. 하지만 매 업데이트 마다 하나만 존재하는 컴포넌트(데이터)에 접근하기 위해 루프를 형성하고 순회하는 것은 효율적이지 못하다.