서비스
교육 철학

업데이트 메서드 (Update Method)

업데이트 메서드 (Update Method)

10.1 의도

컬렉션에 있는 각 객체에 한 프레임만큼의 작업을 진행하라고 알려, 전체를 시뮬레이션합니다.

10.2 동기

우리는 강력한 발키리가 되어 눈부시게 아름다운 보석을 훔치는 퀘스트를 진행하고 있습니다. 보석은 오래전에 죽은 마술사왕의 유골 위에 놓여 있습니다. 발키리는 장엄한 무덤의 입구에 살금살금 다가갔고, 공격을 받……지 않았습니다. 저주받은 석상은 번개를 쏘지 않았고, 언데드 전사는 입구를 지키지 않았습니다. 우리는 그냥 무덤에 들어가 보석을 가져왔습니다. 게임이 끝났고 승리했습니다.
흠, 이래서는 안 됩니다.
무덤은 우리가 무찔러야 할 경비병이 지켜야 합니다. 먼저 해골 병사를 되살려 문 주위를 순찰하게 만들겠습니다. 게임 프로그래밍을 전혀 모른다고 가정하고, 해골 병사가 비틀거리며 왕복하는 코드를 가장 간단히 작성한다면 다음과 같을 것입니다.
원주 10-1 · 옮긴이: 발키리는 북유럽 신화에 등장하는 여자 전사입니다.
원주 10-2: 마술사왕이 더 똑똑한 부하를 원했다면, 뇌 조직이 조금이라도 남아 있는 부하들을 부활시켜야 했을 것입니다.
while (true) { // 오른쪽으로 갑니다. for (double x = 0; x < 100; x++) { skeleton.setX(x); } // 왼쪽으로 갑니다. for (double x = 100; x > 0; x--) { skeleton.setX(x); } }
C++
복사
하지만 무한 루프 때문에 플레이어는 해골 병사가 순찰하는 모습을 볼 수 없습니다. 전혀 재미없는 결과입니다. 우리가 원하는 것은 해골이 한 프레임에 한 걸음씩 움직이는 것입니다.
여기의 루프를 없애고 외부 게임 루프가 반복을 맡도록 바꿔야 합니다. 그러면 순찰 도중에도 입력에 반응하고 화면을 계속 그릴 수 있습니다.
Entity skeleton; bool patrollingLeft = false; double x = 0; // 게임 메인 루프 while (true) { if (patrollingLeft) { x--; if (x == 0) patrollingLeft = false; } else { x++; if (x == 100) patrollingLeft = true; } skeleton.setX(x); // 사용자 입력을 처리하고 게임을 렌더링합니다... }
C++
복사
앞의 코드와 비교하면 복잡해졌습니다. 이전에는 단순한 두 루프로 좌우 순찰을 표현했습니다. 어느 루프를 실행 중인지에 따라 이동 방향을 암시적으로 알 수 있었습니다. 이제는 매 프레임 외부 게임 루프로 돌아갔다가 중단했던 행동을 이어야 하므로, patrollingLeft에 방향을 명시적으로 기록해야 합니다.
원주 10-3: 물론 게임 루프도 이 책의 9장에서 다루는 패턴입니다.
어쨌든 동작하니 계속 진행하겠습니다. 두뇌 없는 뼈다귀는 북유럽 아가씨에게 너무 쉬운 상대입니다. 긴장을 늦추지 못하도록 번개를 자주 쏘는 마법 석상 두 개를 추가해 보겠습니다. 지금처럼 ‘가장 간단한’ 방법으로 작성합니다.
// 해골 병사용 변수들... Entity leftStatue; Entity rightStatue; int leftStatueFrames = 0; int rightStatueFrames = 0; // 게임 메인 루프 while (true) { // 해골 병사용 코드... if (++leftStatueFrames == 90) { leftStatueFrames = 0; leftStatue.shootLightning(); } if (++rightStatueFrames == 80) { rightStatueFrames = 0; rightStatue.shootLightning(); } // 사용자 입력을 처리하고 게임을 렌더링합니다... }
C++
복사
코드가 점점 유지보수하기 어려워집니다. 메인 루프에는 서로 다르게 처리해야 하는 게임 개체의 변수와 실행 코드가 가득합니다. 모두 한 번에 실행하려다 보니 코드를 한데 뭉쳐 놓아야 합니다.
해결책은 간단해서 이미 떠올린 분도 있을 것입니다. 각 개체가 자기 동작을 캡슐화하면 됩니다. 그러면 게임 루프를 어지럽히지 않고 개체를 추가하거나 삭제할 수 있습니다.
이를 위해 추상 메서드 update()를 정의해 추상화 계층을 추가합니다. 게임 루프는 정확한 자료형을 몰라도 ‘업데이트할 수 있다’는 사실만 알고 객체 컬렉션을 관리합니다. 각 객체의 동작을 게임 엔진과 다른 객체로부터 분리하는 것입니다.
매 프레임마다 컬렉션을 순회하며 update()를 호출하면, 각 객체는 한 프레임만큼 자신의 동작을 진행합니다. 덕분에 모든 게임 객체가 동시에 동작하는 것처럼 보입니다.
게임 루프가 객체를 관리하는 동적 컬렉션을 가지므로 컬렉션에 추가하거나 삭제하는 것만으로 레벨에 객체를 넣고 뺄 수 있습니다. 더 이상 모두 하드코딩하지 않아도 됩니다. 레벨 디자이너는 데이터 파일을 이용해 원하는 레벨을 만들 수 있습니다.
원주 10-4: 아키텍처 문서에 ‘뭉쳐 놓았다’는 말이 등장한다면 무언가 잘못된 것입니다.
원주 10-5: 정확히 말하면 한 객체를 업데이트하는 동안 다른 객체는 그대로 있으므로 진짜 동시 실행은 아닙니다. 뒤에서 다시 살펴보겠습니다.
💡
핵심을 구분하기: 게임 루프는 ‘언제 한 프레임을 진행할지’를 맡고, 업데이트 메서드는 ‘각 객체가 그 한 프레임에 무엇을 할지’를 맡습니다. 적이 3마리에서 300마리가 되어도 메인 루프에 적마다 별도의 코드 조각을 추가할 필요가 없어집니다.

10.3 패턴

게임 월드는 객체 컬렉션을 관리합니다. 각 객체는 한 프레임 단위의 동작을 시뮬레이션하는 업데이트 메서드를 구현합니다. 매 프레임마다 게임은 컬렉션 안의 모든 객체를 업데이트합니다.

10.4 언제 쓸 것인가?

게임 루프가 가장 중요한 패턴이라면 업데이트 메서드는 그다음입니다. 플레이어와 상호작용하며 살아 움직이는 개체가 많은 게임에서는 어떤 형태로든 사용하게 됩니다. 우주 해병, 용, 화성인, 유령, 운동선수 등이 있다면 이 패턴을 쓰고 있을 가능성이 높습니다.
반면 게임이 더 추상적이거나, 움직이는 것들이 살아 있는 생물보다 체스 말에 가깝다면 적합하지 않을 수 있습니다. 체스에서는 모든 말을 동시에 시뮬레이션하거나 매 프레임마다 업데이트할 필요가 없을 것입니다.
다음과 같은 경우에 사용합니다.
•
동시에 동작해야 하는 객체나 시스템이 많습니다.
•
각 객체의 동작이 다른 객체와 거의 독립적입니다.
•
시간의 흐름에 따라 객체를 시뮬레이션해야 합니다.
원주 10-6: 보드게임에서도 말의 게임 규칙상 동작은 매 프레임 업데이트할 필요가 없지만, 애니메이션은 매 프레임 업데이트해야 합니다. 이때도 업데이트 메서드가 도움이 됩니다.

10.5 주의사항

패턴 자체는 단순하므로 특별히 조심할 것은 많지 않습니다. 그래도 모든 코드는 어떤 형태로든 영향을 줍니다.
한 프레임씩 끊어 실행하는 코드가 더 복잡합니다
앞의 두 순찰 예제 중 두 번째가 조금 더 복잡했습니다. 둘 다 해골을 앞뒤로 움직이지만, 두 번째는 게임 루프가 매 프레임 다시 호출할 수 있도록 제어권을 돌려줍니다.
입력과 렌더링 등을 게임 루프가 처리하려면 거의 언제나 두 번째와 같은 형태가 필요합니다. 따라서 첫 번째 예제는 사실 현실적이지 않습니다. 다만 동작을 프레임마다 조금씩 실행하도록 나누면 코드가 복잡해지고 구현 비용이 늘어난다는 점은 알아두어야 합니다.
원주 10-7: ‘거의’라고 한 이유는, 중간에 반환하지 않는 직관적인 코드를 유지하면서 여러 객체를 게임 루프 안에서 실행하는 방법도 있기 때문입니다. 여러 실행 ‘스레드’를 운영하며 객체 코드가 반환하는 대신 잠시 멈췄다가 재개할 수 있다면 더 직관적으로 작성할 수 있습니다. 일반 스레드는 이 용도로 너무 무겁지만, 언어가 제너레이터(generator), 코루틴(coroutine), 파이버(fiber) 같은 가벼운 동시성 구조를 지원한다면 활용할 수 있습니다. 11장의 바이트코드 패턴도 애플리케이션 수준에서 여러 실행 스레드를 만드는 방법 중 하나입니다.
다음 프레임을 위해 현재 상태를 저장해야 합니다
첫 예제에서는 어느 코드가 실행되고 있는지만으로 해골의 이동 방향을 알 수 있었습니다. ‘한 프레임에 한 번’ 호출하도록 바꾸자 방향을 저장하는 patrollingLeft가 필요해졌습니다. 함수가 반환하면 이전 실행 위치를 알 수 없으므로, 다음 프레임에 이어서 실행하기에 충분한 정보를 따로 저장해야 합니다.
이럴 때 상태 패턴이 유용할 수 있습니다. 상태 기계가 게임에 자주 쓰이는 이유 중 하나는, 중단했던 동작으로 돌아가기 위해 필요한 상태를 저장하기 때문입니다.
매 프레임 모두 시뮬레이션해도 진짜 동시 실행은 아닙니다
게임 루프는 컬렉션을 순회하며 모든 객체를 업데이트합니다. 각 update()는 게임 월드의 다른 상태, 특히 함께 업데이트 중인 다른 객체에 접근할 수 있습니다. 그러므로 업데이트 순서가 중요합니다.
목록에서 A가 B보다 앞에 있다면 A는 B의 이전 프레임 상태를 봅니다. B를 업데이트할 때는 A가 이미 업데이트되었으므로 A의 현재 프레임 상태를 봅니다. 플레이어에게는 동시에 움직이는 것처럼 보여도 내부에서는 순서대로 처리하며, 그 전체를 한 프레임 안에 끝내는 것입니다.
순차 업데이트는 게임 로직을 만들기 편하게 합니다. 병렬로 업데이트하면 서로 충돌할 수 있습니다. 체스에서 흑과 백이 동시에 같은 칸으로 이동하려 한다면 어떻게 해야 할까요? 순차 업데이트는 이런 문제를 피합니다. 게임 월드의 유효한 상태를 유지하면서 하나씩 바꿔 나가면 상태가 꼬이지 않으므로 중재할 일이 없습니다.
원주 10-8: 이런 순차적 영향을 없애려면 이중 버퍼 패턴 같은 방법이 필요합니다. A와 B가 모두 이전 프레임의 상태를 보도록 하면 둘의 처리 순서가 중요하지 않게 됩니다.
원주 10-9: 여러 이동을 직렬화하여 네트워크로 전송할 수도 있으므로 온라인 게임을 만들기에도 좋습니다.
업데이트 도중 객체 목록을 바꿀 때 주의합니다
많은 게임 동작이 업데이트 메서드에 들어가므로, 업데이트할 객체를 추가하거나 삭제하는 동작도 그 안에 들어가게 됩니다.
해골 경비병을 죽이면 아이템이 떨어진다고 하겠습니다. 새 객체는 보통 목록 끝에 추가하면 됩니다. 계속 순회하다 보면 새 아이템까지 도달해 그것도 업데이트하게 됩니다.
하지만 그러면 플레이어가 스폰된 모습을 보기 전부터 새 객체가 해당 프레임에서 동작합니다. 이를 원하지 않으면 루프를 시작하기 전에 객체 수를 저장하고 그 수만큼만 업데이트합니다.
int numObjectsThisTurn = numObjects_; for (int i = 0; i < numObjectsThisTurn; i++) { objects_[i]->update(); }
C++
복사
objects_는 업데이트 가능한 객체 배열이고 numObjects_는 객체 수입니다. 객체가 추가되면 numObjects_가 증가하지만, 루프 전의 개수를 numObjectsThisTurn에 저장했으므로 이번 프레임에 추가한 객체 앞에서 순회를 멈춥니다.
삭제는 더 어렵습니다. 괴물을 죽였다면 목록에서 빼야 합니다. 현재 업데이트 중인 객체보다 앞의 객체를 삭제하면 의도치 않게 하나를 건너뛸 수 있습니다.
for (int i = 0; i < numObjects_; i++) { objects_[i]->update(); }
C++
복사
이 간단한 루프는 매번 인덱스를 증가시킵니다. 영웅을 업데이트할 차례에 배열이 다음과 같다고 하겠습니다.
그림 10-1. 순회 도중 괴물을 삭제했더니 농부를 건너뜁니다
i가 1일 때 영웅이 괴물을 죽여 배열에서 제거했습니다. 영웅은 0번으로, 불쌍한 농부는 1번으로 당겨집니다. 영웅의 업데이트가 끝나면 i는 2가 됩니다. 그림 오른쪽처럼 농부를 건너뛰어 업데이트하지 못합니다.
삭제할 때 이 상황을 고려해 순회 변수 i도 조정할 수 있습니다. 또는 순회가 끝날 때까지 삭제를 미룰 수 있습니다. 객체에 ‘죽었음’ 표시만 하고 남겨 두었다가, 업데이트 중 죽은 객체를 만나면 건너뜁니다. 전체 순회가 끝난 뒤 다시 목록을 돌면서 시체를 제거합니다.
원주 10-10: 업데이트 루프가 멀티스레드로 실행된다면, 도중에 비싼 스레드 동기화를 하지 않기 위해서라도 목록 변경을 지연하는 것이 더욱 중요합니다.
💡
게임에서 활용하기: 적이 죽으면서 전리품을 생성하는 처리에서는 ‘전리품 생성 요청’과 ‘죽은 적 제거 요청’을 모았다가 업데이트 순회 후 적용할 수 있습니다. 그러면 새 객체가 언제부터 행동하는지와 삭제 대상이 언제 사라지는지를 일정한 규칙으로 만들 수 있습니다. 단순히 개수만 저장하는 방법은 뒤에 추가할 때의 예제이며, 재할당이나 삭제로 참조·인덱스가 무효화되는 컬렉션에서는 별도 처리가 필요합니다.

10.6 예제 코드

예제가 꼭 필요할까 싶을 정도로 단순한 패턴입니다. 그렇다고 쓸모없다는 뜻은 아닙니다. 오히려 단순하고 문제를 깔끔하게 해결하므로 유용합니다.
완결성을 위해 간단히 구현해 보겠습니다. 해골 병사와 석상을 표현할 Entity부터 만듭니다.
class Entity { public: Entity() : x_(0), y_(0) {} virtual ~Entity() {} virtual void update() = 0; double x() const { return x_; } double y() const { return y_; } void setX(double x) { x_ = x; } void setY(double y) { y_ = y; } private: double x_; double y_; };
C++
복사
설명에 필요한 코드만 넣었습니다. 실제 게임에는 그래픽과 물리 처리를 비롯한 코드가 더 있을 것입니다. 지금 중요한 것은 추상 메서드 update()입니다.
게임은 개체 컬렉션을 관리합니다. 여기서는 게임 월드를 대표하는 클래스에 맡기겠습니다.
class World { public: World() : numEntities_(0) {} void gameLoop(); private: Entity* entities_[MAX_ENTITIES]; int numEntities_; };
C++
복사
이제 매 프레임마다 개체를 업데이트하면 패턴 구현이 끝납니다.
void World::gameLoop() { while (true) { // 사용자 입력 처리... // 각 개체를 업데이트합니다. for (int i = 0; i < numEntities_; i++) { entities_[i]->update(); } // 물리, 렌더링... } }
C++
복사
원주 10-11: 실제라면 컬렉션 클래스를 사용했겠지만, 예제를 단순하게 만들려고 일반 배열을 사용했습니다.
원주 10-12: 메서드 이름에서 알 수 있듯이 9장의 게임 루프 패턴을 사용했습니다.
개체를 상속받는다고요?!
객체마다 다른 동작을 정의하기 위해 Entity를 상속한다는 말에 놀라는 분도 있을 것입니다. 무엇이 문제인지 모르는 분을 위해 상황을 설명하겠습니다.
6502 어셈블리 코드와 VBLANK라는 원시 바다에서 나온 게임 산업이 객체지향 언어라는 육지에 올라오자, 많은 개발자가 소프트웨어 아키텍처의 열풍에 뛰어들었습니다. 특히 상속에 심취했습니다. 클래스 상속 구조는 바벨탑처럼 높고 거대하게 쌓여 해를 가릴 지경이었습니다.
시간이 흐르며 이런 거대한 구조가 형편없다는 사실을 깨달았습니다. 쪼개지 않고서는 유지보수가 불가능했습니다. GoF도 1994년에 이미 알고 있었기에 “클래스 상속보다는 객체 조합이 낫다”라고 썼습니다.
이 깨달음이 업계에 퍼질 무렵 나온 해결책이 컴포넌트 패턴입니다. 이 패턴에서는 update()가 개체 클래스 대신 개체의 컴포넌트에 있습니다. 동작을 정의하고 재사용하기 위해 개체의 상속 구조를 복잡하게 만들 필요 없이, 필요한 컴포넌트를 골라 넣으면 됩니다.
여기서도 실제 게임 코드라면 컴포넌트를 사용했을 것입니다. 하지만 이번 장의 주제는 컴포넌트가 아니라 업데이트 메서드입니다. 코드를 적게 고치고 패턴을 가장 단순하게 보여주려고 개체 클래스에 메서드를 두고 상속합니다.
원주 10-13 · 옮긴이: 아타리, 애플 II, 패미컴(NES)에는 MOS 6502 계열 프로세서가 들어갔습니다.
원주 10-14 · 옮긴이: VBLANK는 수직 귀선 기간(vertical blanking interval)의 약자로, 수직 동기화(VSync)와 관련된 용어입니다.
원주 10-15: 우리끼리 이야기하자면 상속을 반대하는 쪽으로도 너무 멀리 갔다고 생각합니다. 저도 상속을 피하는 편이지만, 무조건 사용하지 않는 태도 역시 무조건 사용하는 것만큼 나쁩니다. 너무 엄격하게 굴지 말고 필요하면 적절히 사용합시다.
원주 10-16: 이 책은 14장에서 컴포넌트 패턴도 설명합니다.
개체 정의
다시 해골 경비병과 번개를 쏘는 석상으로 돌아오겠습니다. 먼저 해골의 순찰을 정의하도록 update()를 구현한 새 개체를 만듭니다.
class Skeleton : public Entity { public: Skeleton() : patrollingLeft_(false) {} virtual void update() { if (patrollingLeft_) { setX(x() - 1); if (x() == 0) patrollingLeft_ = false; } else { setX(x() + 1); if (x() == 100) patrollingLeft_ = true; } } private: bool patrollingLeft_; };
C++
복사
앞서 게임 루프에 있던 코드를 거의 그대로 가져왔습니다. 차이는 지역 변수였던 patrollingLeft_가 멤버 변수가 되었다는 정도입니다. 덕분에 update()가 반환한 뒤에도 값을 유지합니다.
석상도 정의하겠습니다.
class Statue : public Entity { public: Statue(int delay) : frames_(0), delay_(delay) {} virtual void update() { if (++frames_ == delay_) { shootLightning(); // 타이머를 초기화합니다. frames_ = 0; } } private: int frames_; int delay_; void shootLightning() { // 번개를 쏩니다... } };
C++
복사
이 클래스도 게임 루프의 코드를 대부분 가져와 이름만 바꾸었습니다. 오히려 더 단순해졌습니다. 처음의 너저분한 명령형 코드에서는 석상마다 프레임 카운터와 발사 주기를 따로 관리해야 했습니다.
이제 변수를 Statue에 옮겼으므로 석상 인스턴스가 각자 타이머를 관리합니다. 원하는 만큼 석상을 만들 수 있습니다. 이것이 업데이트 메서드를 사용하는 진짜 숨은 동기입니다. 객체가 필요한 것을 직접 가지고 관리하므로 새로운 개체를 게임 월드에 쉽게 추가할 수 있습니다.
개체를 추가하기 위해 게임 루프에 별도의 동작 코드를 구현할 필요가 없어집니다. 데이터 파일이나 레벨 에디터를 통해 유연하게 월드를 구성할 수 있습니다.
원주 10-17 · 옮긴이: 앞의 ‘동기’ 절에서는 카운터를 leftStatueFrames, rightStatueFrames로 관리했고, 발사 주기는 각각 상수 90과 80으로 하드코딩했습니다.
그림 10-2. 방금 만든 코드를 UML 클래스 다이어그램으로 나타냈습니다. 요즘도 UML을 신경 쓰는 사람이 있을지는 모르겠습니다
💡
그림 읽기: World는 Entity*만 알면 됩니다. 실제로 순찰할지 번개를 쏠지는 각각의 update()가 결정합니다. 이 예제의 석상은 delay_가 양수인 프레임 수라고 가정합니다. 데이터에서 주기를 읽는 실제 구현에서는 0이나 음수를 허용할지도 정해야 합니다. PDF 본문에 등장하는 Status는 코드와 그림의 클래스명인 Statue의 오기여서 여기서는 Statue로 바로잡았습니다.
시간 전달
핵심은 완성했지만 조금 더 다듬어 보겠습니다. 지금까지는 update()를 부를 때마다 게임 월드가 같은 고정 단위 시간만큼 진행한다고 가정했습니다.
저자는 이 방식을 선호하지만, 가변 시간 간격을 사용하는 게임도 많습니다. 가변 방식에서는 이전 프레임의 진행과 렌더링에 실제로 걸린 시간에 따라 다음 시뮬레이션 간격을 길거나 짧게 잡습니다.
따라서 update()는 시간이 얼마나 지났는지 알아야 하며, 이를 인수로 받습니다. 해골 경비병의 가변 시간 간격 처리는 다음과 같습니다.
void Skeleton::update(double elapsed) { if (patrollingLeft_) { x -= elapsed; if (x <= 0) { patrollingLeft_ = false; x = -x; } } else { x += elapsed; if (x >= 100) { patrollingLeft_ = true; x = 100 - (x - 100); } } }
C++
복사
지난 시간이 길수록 이동 거리도 늘어납니다. 가변 시간 간격을 처리하느라 코드가 더 복잡해졌습니다. 간격이 길면 해골이 순찰 범위를 넘어갈 수 있으므로 주의해야 합니다.
원주 10-18: 고정·가변 시간 간격의 장단점은 9장의 게임 루프 패턴에서 자세히 다룹니다.
원주 10-19 · 옮긴이: x = -x;와 x = 100 - (x - 100);가 추가되었습니다.
💡
예제 코드 주의: 이 가변 시간 예제는 책의 설명용 코드 그대로이며, 앞의 Entity에는 x라는 공개 변수가 없으므로 그대로 붙여 넣어 컴파일할 수는 없습니다. 실제 구현에서는 x()로 현재 값을 읽고 setX()로 저장하며, 기반 클래스와 파생 클래스의 update(double) 시그니처도 함께 바꿔야 합니다. 또 매우 큰 elapsed로 경계를 여러 번 넘으면 한 번의 반사만으로 부족합니다. 고정 간격으로 나누어 처리하거나 여러 번의 왕복을 반영하는 계산이 필요합니다.

10.7 디자인 결정

단순한 패턴이므로 변형은 많지 않지만 조율할 부분은 있습니다.
업데이트 메서드를 어느 클래스에 둘 것인가?
가장 분명하면서도 중요한 결정은 update()를 어디에 두느냐입니다.
개체 클래스:
이미 개체 클래스가 있다면 새 클래스를 추가하지 않아도 되므로 가장 간단합니다. 개체 종류가 적다면 괜찮겠지만, 업계는 이 방식에서 점차 멀어지고 있습니다.
종류가 많은데 새로운 동작마다 개체 클래스를 상속해야 한다면 코드가 깨지기 쉽고 작업하기 어렵습니다. 언젠가는 단일 상속 구조로 코드를 매끄럽게 재사용할 수 없는 순간이 옵니다. 그러면 막다른 길에 이릅니다.
컴포넌트 클래스:
이미 컴포넌트 패턴을 사용한다면 고민할 필요가 없습니다. 컴포넌트가 스스로 업데이트하게 하면 됩니다. 업데이트 메서드 패턴이 게임 개체를 월드의 다른 개체와 분리하듯, 컴포넌트 패턴은 한 개체의 각 부분을 다른 부분과 분리합니다. 렌더링, 물리, AI가 각자 스스로 돌아갑니다.
위임 클래스:
클래스 동작의 일부를 다른 객체에 위임하는 패턴도 있습니다. 상태 패턴은 위임 대상인 상태 객체를 바꾸어 동작을 변경합니다. 타입 객체 패턴은 같은 종류의 여러 개체가 동작을 공유하게 합니다. 이런 패턴을 사용한다면 위임 클래스에 update()를 두는 것이 자연스럽습니다.
개체에도 update()는 남지만, 가상 함수가 아니라 다음처럼 위임 객체에 호출을 전달하는 함수가 됩니다.
void Entity::update() { // 상태 객체에 전달합니다. state_->update(); }
C++
복사
새로운 동작을 원하면 위임 객체를 바꾸면 됩니다. 컴포넌트와 마찬가지로 새 파생 클래스를 정의하지 않고도 동작을 바꿀 수 있는 유연성을 얻습니다.
휴면 객체 처리
여러 이유로 일시적으로 업데이트가 필요 없는 객체가 생깁니다. 사용 불능 상태이거나 화면 밖에 있거나 아직 잠겨 있을 수도 있습니다. 이런 객체가 많으면 매 프레임 불필요하게 순회하며 CPU 시간을 낭비합니다.
대안은 업데이트가 필요한 ‘살아 있는’ 객체만 별도 컬렉션에 모으는 것입니다. 비활성화되면 그 컬렉션에서 제거하고, 활성화되면 다시 추가합니다. 그러면 실제 작업이 필요한 객체만 순회합니다.
비활성 객체까지 포함하는 컬렉션 하나만 사용한다면:
•
시간을 낭비합니다. 비활성 객체에서는 활성 여부 플래그를 확인하거나 아무것도 하지 않는 메서드를 호출할 뿐입니다.
활성 객체만 모은 컬렉션을 하나 더 둔다면:
•
메모리를 더 사용합니다. 모든 개체를 대상으로 작업할 수도 있으므로 전체를 모은 마스터 컬렉션도 보통 필요합니다. 활성 객체 컬렉션은 엄밀히 말하면 중복 데이터입니다. 대개 그렇듯 메모리보다 속도가 중요하다면 받아들일 만합니다. 절충안으로는 컬렉션 둘을 두되 하나에 활성 객체, 다른 하나에 전체 대신 비활성 객체만 모을 수도 있습니다.
•
두 컬렉션을 동기화해야 합니다. 객체를 생성하거나, 잠시 비활성화하는 것이 아니라 완전히 소멸시키면 주 컬렉션과 활성 컬렉션을 모두 바꿔야 합니다.
원주 10-20: 비활성 객체를 확인하고 건너뛰는 일은 CPU 시간뿐 아니라 데이터 캐시도 낭비합니다. CPU는 읽기를 최적화하려고 RAM의 데이터를 훨씬 빠른 온칩 캐시(on-chip cache)로 가져옵니다. 지금 읽는 데이터 옆의 데이터도 곧 읽을 가능성이 높다고 가정하기 때문입니다. 객체를 건너뛰면 필요한 데이터가 캐시에 없어 느린 메모리에 다시 접근해야 할 수 있습니다. 17장의 데이터 지역성 패턴에서 자세히 다룹니다.
어느 방법을 쓸지는 평소 비활성 상태로 남는 객체가 얼마나 많은지에 따라 결정하면 됩니다. 비활성 객체가 많을수록 별도 컬렉션의 이점이 커집니다.
💡
활성 상태를 정할 때: 화면 밖이라는 이유만으로 모든 업데이트를 멈추면 안 되는 게임도 있습니다. 플레이어가 보지 않는 곳의 생산 시설이나 적 추격은 계속 진행되어야 할 수 있습니다. ‘보이는가’와 ‘시뮬레이션이 필요한가’를 분리해서 활성 컬렉션의 기준을 정하세요.

10.8 관련 자료

•
업데이트 메서드는 게임 루프(9장), 컴포넌트(14장)와 함께 게임 코드의 핵심을 이룹니다.
•
많은 개체나 컴포넌트를 매 프레임 업데이트할 때 캐시 성능이 걱정되면 데이터 지역성 패턴(17장)을 확인하세요.
•
유니티는 MonoBehaviour를 비롯한 여러 클래스에 이 패턴을 사용합니다.
•
마이크로소프트 XNA는 Game, GameComponent에 이 패턴을 사용합니다.
•
자바스크립트 게임 엔진 Quintus도 주요 Sprite 클래스에 이 패턴을 사용합니다.