서비스
교육 철학

컴포넌트 (Component)

5부 · 디커플링 패턴 (Decoupling Patterns)

프로그래밍 언어를 익히고 나면 원하는 기능을 코드로 쓰는 일은 그렇게 어렵지 않습니다. 진짜 어려운 일은 요구사항이 바뀌었을 때 쉽게 고칠 수 있는 코드를 만드는 것입니다. 구현 전에 모든 요구사항이 완벽하게 준비되는 경우는 거의 없습니다.
이때 강력한 도구가 디커플링(decoupling)입니다. 한쪽을 수정해도 다른 쪽을 거의 바꾸지 않아도 된다면 두 코드는 서로 디커플링되어 있다고 합니다. 게임의 기능을 변경할 때 손댈 코드가 적을수록 수정하기 쉽습니다.
컴포넌트 패턴은 한 개체 안의 코드를 기능별로 분리합니다. 이벤트 큐는 통신하는 두 객체를 코드뿐 아니라 시간 면에서도 분리합니다. 서비스 중개자는 기능을 실제로 제공하는 코드와 직접 결합하지 않고도 그 기능에 접근하게 해줍니다.
•
14장 · 컴포넌트
•
15장 · 이벤트 큐
•
16장 · 서비스 중개자

컴포넌트 (Component)

14.1 의도

한 개체가 여러 분야를 다루면서도 그 분야들이 서로 결합하지 않게 합니다.

14.2 동기

플랫포머 게임을 만든다고 해보겠습니다. 이탈리아 배관공이 주인공인 게임은 이미 있으니 덴마크 제빵사 비외른(Bjorn)을 주인공으로 정하겠습니다. 그를 나타내는 클래스를 하나 만들고, 게임에서 주인공이 하는 모든 일을 넣는 것이 당연해 보입니다.
캐릭터를 조종하려면 컨트롤러 입력을 읽어 행동으로 바꾸어야 합니다. 지형과 발판 등 레벨과 상호작용하려면 물리와 충돌 처리도 필요합니다. 화면에 보여야 하므로 애니메이션과 렌더링을 넣고, 당연히 소리도 들려주어야 합니다.
점점 엉망이 되어갑니다. 소프트웨어 구조 입문 수업에서는 다른 분야의 코드를 서로 격리하라고 배웠습니다. 워드 프로세서의 인쇄 코드가 문서 열기나 저장 코드에 영향을 받으면 안 되는 것처럼, 게임에도 이 규칙이 적용됩니다.
AI, 물리, 렌더링, 사운드처럼 다른 분야는 서로 최대한 모르는 편이 좋습니다. 모두 한 클래스에 밀어 넣으면 5천 줄이 넘는 거대한 쓰레기 코드가 되어 팀에서 가장 용감한 닌자 코더만 감히 손댈 수 있게 됩니다.
이에 적응한 몇몇 프로그래머에게는 직업 안정성이 생길지 몰라도, 나머지에게는 지옥과 같습니다. 클래스가 크면 사소한 수정에도 엄청난 작업이 필요할 수 있습니다. 곧 기능보다 버그가 더 빠르게 늘어날 것입니다.
원주 14-1: 이런 천재적인(?) 아이디어를 보면 저자가 왜 기획자가 아니라 프로그래머인지 알 수 있습니다.
원주 14-2 · 옮긴이: 닌자 코더는 어려운 일을 조용하고 깔끔하게 처리하는 실력 좋은 프로그래머를 뜻합니다.
원주 14-3 · 옮긴이: 『유지보수하기 어렵게 코딩하는 방법』(한빛미디어, 2012)을 참고하세요.
고르디우스의 매듭
길이보다 더 큰 문제는 결합입니다. 여러 게임 시스템이 주인공 클래스 안에서 실타래처럼 얽힙니다.
if (collidingWithFloor() && (getRenderState() != INVISIBLE)) { playSound(HIT_FLOOR); }
C++
복사
이 코드를 문제없이 수정하려면 물리의 collidingWithFloor(), 그래픽의 getRenderState(), 사운드의 playSound()를 모두 알아야 합니다.
결합과 코드 길이 문제는 서로 악영향을 줍니다. 한 클래스가 너무 많은 분야에 걸쳐 있으니 모든 프로그래머가 작업해야 하는데, 클래스가 너무 커서 작업은 어렵습니다. 심해지면 뒤죽박죽인 Bjorn을 건드리기 싫어 다른 곳에 임시방편 코드를 넣기 시작합니다.
원주 14-4: 결합은 어느 게임에서나 문제지만 멀티스레드 환경에서는 더 심각합니다. 멀티코어 하드웨어에서는 스레드 활용이 중요하며, 게임 코드를 분야별로 나누어 AI·사운드·렌더링을 각각 다른 스레드에서 실행하는 방식이 흔합니다. 이때 분야를 분리해야 교착상태 같은 동시성 버그를 피하기 쉽습니다. 서로 다른 스레드에서 실행할 UpdateSound()와 RenderGraphics() 같은 메서드를 한 클래스에 모으면 이런 버그를 부추길 수 있습니다.
매듭 끊기
알렉산더 대왕이 고르디우스의 매듭을 칼로 끊었듯이, 큰 Bjorn을 분야별로 나누면 됩니다. 예를 들어 입력 관련 코드를 InputComponent로 옮기고 비외른이 이 객체를 가지게 합니다. 나머지 분야에도 같은 작업을 반복합니다.
그러면 비외른 클래스에는 여러 컴포넌트를 묶는 얇은 껍데기만 남습니다. 여러 작은 클래스로 나누어 크기 문제를 해결했습니다. 이득은 여기에서 끝나지 않습니다.
열린 구조
컴포넌트들은 서로 분리됩니다. PhysicsComponent와 GraphicsComponent가 같은 비외른 안에 있어도 서로를 알지 않습니다. 물리 담당자는 화면 처리를 신경 쓰지 않고 자기 코드를 수정할 수 있으며, 반대도 마찬가지입니다.
물론 실제로는 상호작용이 필요할 수 있습니다. AI가 비외른이 가려는 곳을 물리 컴포넌트를 통해 알아내야 할 수도 있습니다. 그래도 모든 코드를 한곳에 섞지 않았으므로, 통신이 필요한 컴포넌트 사이로 결합을 제한할 수 있습니다.
다시 합치기
분리한 컴포넌트는 재사용할 수 있습니다. 주인공 외에 필요한 게임 객체들을 생각해 보겠습니다. 데커레이션(decoration)은 덤불이나 먼지처럼 보이지만 상호작용할 수 없는 객체입니다. 프랍(prop)은 상자, 바위, 나무처럼 보이면서 상호작용할 수 있습니다. 존(zone)은 데커레이션과 반대로 보이지 않지만 상호작용할 수 있습니다. 비외른이 특정 영역에 들어올 때 컷신을 보여주고 싶다면 존을 이용할 수 있습니다.
원주 14-5: 객체지향 초창기에는 상속을 코드 재사용을 위한 만능 망치처럼 여겨 지나치게 사용하는 경우가 많았습니다. 시행착오 끝에 상속이 너무 무거운 도구일 수도 있음을 알게 되었습니다. 상속에는 쓰임새가 있지만 단순한 재사용에는 번거로울 때가 많습니다. 대신 조합(composition)을 선호하는 설계에서는 두 클래스가 코드를 공유할 때 같은 부모를 상속하기보다 같은 종류의 객체를 멤버로 가집니다.
컴포넌트 없이 상속으로 구성하면 다음과 같은 구조를 떠올리기 쉽습니다.
그림 14-1. 단일 상속만으로 충돌과 렌더링 두 갈래를 동시에 재사용하기 어렵습니다
GameObject에는 위치와 방향 같은 기본 데이터를 둡니다. 이를 상속한 Zone은 충돌 검사를, Decoration은 렌더링을 추가합니다. Prop은 충돌 검사를 재사용하려고 Zone을 상속합니다. 그런데 렌더링도 재사용하려고 Decoration까지 상속하는 순간 ‘죽음의 다이아몬드(Deadly Diamond)’라는 다중 상속 문제가 생깁니다.
반대로 프랍이 데커레이션을 상속하면 충돌 코드의 중복을 피하지 못합니다. 어느 쪽이든 다중 상속 없이 두 기능을 깔끔하게 재사용하기 어렵습니다. 모든 기능을 상위 GameObject로 올릴 수도 있지만, 그러면 존에 필요 없는 렌더링 데이터가 생기고 데커레이션에 쓰지 않는 물리 기능이 들어갑니다.
컴포넌트로 만들면 이런 상속이 필요 없습니다. GameObject 하나와 PhysicsComponent, GraphicsComponent 두 클래스면 됩니다. 데커레이션에는 그래픽만, 존에는 물리만, 프랍에는 둘 다 넣습니다. 중복 코드도 다중 상속도 없고, 클래스 수는 네 개에서 세 개로 줄었습니다.
컴포넌트는 객체를 위한 플러그 앤 플레이입니다. 개체의 소켓에 재사용 가능한 부품을 끼워 기능이 풍부한 개체를 만듭니다. 미래용사 볼트론처럼요.
원주 14-6: 두 개 이상의 상속 경로가 같은 상위 클래스에서 만나는 상황을 ‘죽음의 다이아몬드’라고 합니다. 여기에서 자세히 다루지는 않지만, ‘죽음의’라는 이름이 괜히 붙은 것은 아닙니다.
원주 14-7: 식당에 비유하면 통짜 클래스는 세트 메뉴와 같습니다. 다양한 손님을 만족시키려면 세트 조합이 많이 필요하듯, 기능 조합마다 별도 클래스를 준비해야 합니다. 컴포넌트는 단품 선택 메뉴(알 라 카르트)입니다. 선택 가능한 요리 목록에서 원하는 것들을 골라 조합합니다.
원주 14-8 · 옮긴이: 안타깝게도 볼트론은 한 가지 방식으로만 합체할 수 있습니다.
💡
게임에서 활용하기: 투명한 퀘스트 진입 영역에는 충돌 감지만, 배경 장식에는 렌더링만, 밀 수 있는 상자에는 둘 다 필요합니다. 필요한 기능을 조합하면 ‘투명한 상자’, ‘장식용 상자’처럼 조합마다 새로운 상속 가지를 만들 필요가 줄어듭니다. 다만 클래스만 나눈다고 의존성이 없어지는 것은 아닙니다. 서로의 내부 필드를 마음대로 바꾸지 않도록 공유 상태의 소유자와 통신 경로도 정해야 합니다.

14.3 패턴

여러 분야를 다루는 한 개체가 있습니다. 분야마다 코드를 별도의 컴포넌트 클래스에 두어 격리합니다. 개체 클래스는 이 컴포넌트들을 담는 컨테이너 역할만 합니다.
원주 14-9: 프로그래밍에서 ‘컴포넌트’는 ‘객체’처럼 여러 뜻으로 쓰이는 단어입니다. 기업용 소프트웨어에서는 웹으로 통신하도록 서비스를 분리하는 설계도 컴포넌트 패턴이라고 부릅니다. 옮긴이는 이를 컴포넌트 기반 개발(component based development)이라고 설명합니다. 게임에서의 개념과는 달라 새 용어도 생각했지만, 이미 가장 널리 쓰이는 이름이었습니다. 디자인 패턴은 기존 개발 방식을 문서화하므로 새 말을 만드는 대신 XNA나 Delta3D처럼 ‘컴포넌트’라는 명칭을 사용합니다.

14.4 언제 쓸 것인가?

주로 게임 개체를 정의하는 핵심 클래스에서 사용하지만 다음 중 하나라도 해당한다면 다른 분야에서도 유용합니다.
•
한 클래스가 여러 분야를 다루어 이를 서로 분리하고 싶습니다.
•
클래스가 너무 커서 작업하기 어렵습니다.
•
다양한 객체가 여러 기능을 공유해야 하는데, 상속으로는 원하는 부분만 선택하여 재사용하기 어렵습니다.

14.5 주의사항

한 클래스에 코드를 모았을 때보다 구조가 복잡해질 수 있습니다. 개념적으로 하나인 객체를 만들기 위해 여러 객체를 생성하고 초기화하고 올바르게 연결해야 하기 때문입니다. 컴포넌트끼리 통신하는 일과 메모리 배치를 제어하는 일도 복잡해집니다.
큰 코드베이스에서는 이런 비용보다 분리와 재사용의 이점이 클 수 있습니다. 하지만 아직 발생하지 않은 문제의 해결책을 과하게 설계하는 것은 아닌지 주의해야 합니다.
무언가를 하려면 중간 단계를 거쳐야 한다는 문제도 있습니다. 컨테이너에서 원하는 컴포넌트를 얻고 그 포인터를 따라가야 할 수 있습니다. 성능에 민감한 내부 루프에서 이런 접근이 반복되면 느려질 수 있습니다.
원주 14-10: 반대로 성능과 캐시 활용이 좋아질 수도 있습니다. 컴포넌트는 CPU가 필요로 하는 순서대로 데이터를 구성하는 데이터 지역성 패턴(17장)을 적용하기 쉽게 해줍니다.

14.6 예제 코드

저자는 책을 쓰는 동안 패턴을 어떻게 독립적으로 보여줄지 고민했습니다. 패턴 예제에는 그 패턴 자체와 관계없는 코드도 필요합니다. 핵심만 남기려다가 너무 많이 줄이면 옷을 하나도 보여주지 않고 옷장 정리법을 설명하는 것과 비슷해집니다.
컴포넌트는 특히 그렇습니다. 분리할 분야의 코드 없이는 패턴을 이해하기 어려워 예제가 원했던 것보다 길어졌습니다. 핵심은 컴포넌트 클래스지만, 그 클래스가 무슨 일을 하는지 코드를 넣어 보여주어야 합니다. 여기에서 정의하지 않은 다른 클래스의 메서드도 호출하는 예시 코드이지만, 설계 의도를 이해하는 데 도움이 될 것입니다.
모든 기능을 담은 통짜 클래스
먼저 컴포넌트를 적용하지 않아 모든 기능이 들어 있는 비외른 클래스입니다.
class Bjorn { public: Bjorn() : velocity_(0), x_(0), y_(0) {} void update(World& world, Graphics& graphics); private: static const int WALK_ACCELERATION = 1; int velocity_; int x_, y_; Volume volume_; Sprite spriteStand_; Sprite spriteWalkLeft_; Sprite spriteWalkRight_; };
C++
복사
원주 14-11: 캐릭터 이름을 코드에 직접 쓰는 것은 그리 좋은 생각이 아닙니다. 사업팀이 출시 직전에 “11~15세 남자아이들의 포커스 테스트에서 비외른이라는 이름에 거부감이 나왔으니 스벤으로 바꾸자”고 할 수도 있습니다. 그래서 많은 프로젝트가 내부 코드명을 씁니다. ‘포토샵 다음 버전’보다 ‘큰 전기 고양이(Big Electric Cat)’를 개발한다고 말하는 편이 더 재미있기도 합니다. 옮긴이에 따르면 이는 1996년 Adobe Photoshop 4.0의 코드명입니다.
update()는 매 프레임 호출됩니다.
void Bjorn::update(World& world, Graphics& graphics) { // 입력에 따라 주인공의 속도를 조절합니다. switch (Controller::getJoystickDirection()) { case DIR_LEFT: velocity_ -= WALK_ACCELERATION; break; case DIR_RIGHT: velocity_ += WALK_ACCELERATION; break; } // 속도에 따라 위치를 바꿉니다. x_ += velocity_; world.resolveCollision(volume_, x_, y_, velocity_); // 알맞은 스프라이트를 그립니다. Sprite* sprite = &spriteStand_; if (velocity_ < 0) { sprite = &spriteWalkLeft_; } else if (velocity_ > 0) { sprite = &spriteWalkRight_; } graphics.draw(*sprite, x_, y_); }
C++
복사
조이스틱에 따라 가속하고, 물리 엔진을 통해 다음 위치를 구한 뒤, 비외른을 화면에 그립니다.
아주 간단한 구현입니다. 중력과 애니메이션도 없고 캐릭터를 재미있게 만드는 상세한 기능도 빠져 있습니다. 그런데도 여러 분야의 프로그래머가 같은 update()를 수정해야 하며 코드가 복잡해지기 시작했습니다. 이것이 수천 줄로 늘어났을 때의 어려움을 짐작할 수 있습니다.
분야별로 나누기
한 분야를 골라 관련 코드를 별도 컴포넌트로 옮깁니다. 가장 먼저 실행되는 입력부터 시작하겠습니다. 사용자의 입력에 따라 속도를 조절하는 부분을 분리합니다.
class InputComponent { public: void update(Bjorn& bjorn) { switch (Controller::getJoystickDirection()) { case DIR_LEFT: bjorn.velocity -= WALK_ACCELERATION; break; case DIR_RIGHT: bjorn.velocity += WALK_ACCELERATION; break; } } private: static const int WALK_ACCELERATION = 1; };
C++
복사
어려운 작업은 아닙니다. 원래 메서드의 앞부분을 옮겼을 뿐입니다. 비외른은 다음처럼 바뀝니다.
class Bjorn { public: int velocity; int x, y; void update(World& world, Graphics& graphics) { input_.update(*this); // 속도에 따라 위치를 바꿉니다. x += velocity; world.resolveCollision(volume_, x, y, velocity); // 알맞은 스프라이트를 그립니다. Sprite* sprite = &spriteStand_; if (velocity < 0) { sprite = &spriteWalkLeft_; } else if (velocity > 0) { sprite = &spriteWalkRight_; } graphics.draw(*sprite, x, y); } private: InputComponent input_; Volume volume_; Sprite spriteStand_; Sprite spriteWalkLeft_; Sprite spriteWalkRight_; };
C++
복사
입력 컴포넌트가 추가되었습니다. 직접 처리하던 입력을 이제 그 객체에 위임합니다.
input_.update(*this);
C++
복사
아직 시작에 불과하지만 비외른은 더 이상 Controller를 참조하지 않습니다. 결합 일부를 제거했고, 이는 뒤에서 유용하게 쓰입니다.
나머지도 나누기
남은 물리와 그래픽 코드도 같은 방식으로 옮깁니다. 먼저 물리입니다.
class PhysicsComponent { public: void update(Bjorn& bjorn, World& world) { bjorn.x += bjorn.velocity; world.resolveCollision(volume_, bjorn.x, bjorn.y, bjorn.velocity); } private: Volume volume_; };
C++
복사
물리 코드를 옮기면서 데이터도 함께 옮겼습니다. 이제 Volume은 비외른이 아니라 물리 컴포넌트에서 관리합니다.
마지막으로 렌더링을 옮깁니다.
class GraphicsComponent { public: void update(Bjorn& bjorn, Graphics& graphics) { Sprite* sprite = &spriteStand_; if (bjorn.velocity < 0) { sprite = &spriteWalkLeft_; } else if (bjorn.velocity > 0) { sprite = &spriteWalkRight_; } graphics.draw(*sprite, bjorn.x, bjorn.y); } private: Sprite spriteStand_; Sprite spriteWalkLeft_; Sprite spriteWalkRight_; };
C++
복사
이제 비외른 클래스에는 거의 코드가 남지 않았습니다.
class Bjorn { public: int velocity; int x, y; void update(World& world, Graphics& graphics) { input_.update(*this); physics_.update(*this, world); graphics_.update(*this, graphics); } private: InputComponent input_; PhysicsComponent physics_; GraphicsComponent graphics_; };
C++
복사
이 클래스의 역할은 두 가지입니다. 자신을 이루는 컴포넌트 집합을 관리하고, 컴포넌트들이 공유하는 상태를 보관합니다.
위치 x, y와 속도 velocity를 비외른에 남긴 이유도 두 가지입니다. 우선 모든 분야에서 사용하는 값이므로 특정 컴포넌트로 옮기기가 애매합니다. 더 중요한 이유는 컴포넌트끼리 서로를 모르면서도 이 공유 상태를 통해 쉽게 통신할 수 있기 때문입니다. 이제 이를 활용해 보겠습니다.
💡
한 프레임을 따라가기: 입력이 속도를 바꾸고 → 물리가 그 속도로 위치를 계산하고 → 그래픽이 새 위치를 읽습니다. 세 클래스가 서로를 직접 참조하지 않아도 데이터 흐름은 남습니다. 실행 순서를 바꾸면 같은 프레임에 읽는 값도 달라지므로, 분리한 뒤에도 순서를 명확히 관리해야 합니다. 원문의 이후 간략화된 클래스에는 좌표·속도 초기화가 생략되어 있으니 실제 구현에서는 생성자나 기본 멤버 초기값으로 초기화하세요.
오토 비외른
동작을 별도 클래스로 옮겼지만 아직 추상화하지는 않았습니다. 비외른은 자기 동작을 어떤 구체 클래스가 구현하는지 정확히 압니다. 이를 바꾸겠습니다.
입력 처리를 인터페이스 뒤에 숨기기 위해 InputComponent를 추상 상위 클래스로 만듭니다.
class InputComponent { public: virtual ~InputComponent() {} virtual void update(Bjorn& bjorn) = 0; };
C++
복사
기존 입력 코드는 이 인터페이스를 구현하는 클래스로 옮깁니다.
class PlayerInputComponent : public InputComponent { public: virtual void update(Bjorn& bjorn) { switch (Controller::getJoystickDirection()) { case DIR_LEFT: bjorn.velocity -= WALK_ACCELERATION; break; case DIR_RIGHT: bjorn.velocity += WALK_ACCELERATION; break; } } private: static const int WALK_ACCELERATION = 1; };
C++
복사
비외른은 구체 컴포넌트를 값으로 가지는 대신 인터페이스 포인터를 저장합니다.
class Bjorn { public: int velocity; int x, y; Bjorn(InputComponent* input) : input_(input) {} void update(World& world, Graphics& graphics) { input_->update(*this); physics_.update(*this, world); graphics_.update(*this, graphics); } private: InputComponent* input_; PhysicsComponent physics_; GraphicsComponent graphics_; };
C++
복사
생성할 때 사용할 입력 컴포넌트를 전달합니다.
Bjorn* bjorn = new Bjorn(new PlayerInputComponent());
C++
복사
어떤 클래스든 추상 입력 인터페이스만 구현하면 사용할 수 있습니다. 가상 호출로 바뀌어 실행 비용이 조금 늘 수 있지만, 무엇을 얻었을까요?
대부분의 콘솔 게임에는 플레이어가 주 메뉴에서 아무것도 하지 않을 때 컴퓨터가 대신 플레이하는 데모 모드가 필요합니다. 주 메뉴만 계속 보여주지 않아도 되고, 매장에서도 게임을 근사하게 소개할 수 있습니다.
입력 구현을 인터페이스 뒤로 숨겼으므로 이런 모드를 만들 수 있습니다. 실제 조작에 쓰는 PlayerInputComponent 외에 다른 클래스를 만듭니다.
class DemoInputComponent : public InputComponent { public: virtual void update(Bjorn& bjorn) { // AI가 알아서 Bjorn을 조종합니다. } };
C++
복사
데모 모드에서는 이를 연결합니다.
Bjorn* bjorn = new Bjorn(new DemoInputComponent());
C++
복사
컴포넌트를 교체하는 것만으로 데모용 AI 플레이어를 만들 수 있습니다. 나머지는 그대로 재사용합니다. 물리와 그래픽 컴포넌트는 무엇이 달라졌는지도 모릅니다. 저자는 이런 멋진 구조를 떠올린 날에는 출근 시간이 기다려진다고 말합니다.
💡
소유권도 설계에 포함하기: 원문의 new 예제는 조합 관계를 보여주며 해제 코드는 생략합니다. 실제 구현에서는 누가 컴포넌트를 소유하고 언제 제거하는지 정해야 합니다. 컨테이너가 독점 소유한다면 std::unique_ptr 같은 수명 관리 방식을 사용할 수 있습니다. 데모 AI 자체는 위 주석 위치에 구현해야 하며, 인터페이스 교체만으로 AI 알고리즘이 자동 생성되는 것은 아닙니다.
꼭 비외른일 필요는 없습니다
이제 클래스는 컴포넌트 묶음일 뿐 비외른 고유의 코드가 없습니다. 모든 게임 객체가 공통으로 쓰는 GameObject로 바꾸는 편이 좋겠습니다. 프랑켄슈타인이 피조물을 만들듯 원하는 부품을 골라 온갖 객체를 만들 수 있습니다.
물리와 그래픽도 인터페이스와 구현으로 분리합니다.
class PhysicsComponent { public: virtual ~PhysicsComponent() {} virtual void update(GameObject& obj, World& world) = 0; }; class GraphicsComponent { public: virtual ~GraphicsComponent() {} virtual void update(GameObject& obj, Graphics& graphics) = 0; };
C++
복사
비외른 클래스는 이름을 범용적인 GameObject로 바꾸고 이 인터페이스들을 사용합니다.
class GameObject { public: int velocity; int x, y; GameObject(InputComponent* input, PhysicsComponent* physics, GraphicsComponent* graphics) : input_(input), physics_(physics), graphics_(graphics) { } void update(World& world, Graphics& graphics) { input_->update(*this); physics_->update(*this, world); graphics_->update(*this, graphics); } private: InputComponent* input_; PhysicsComponent* physics_; GraphicsComponent* graphics_; };
C++
복사
원주 14-12: 일부 시스템은 한 단계 더 나아가 개체를 컴포넌트 포인터를 저장하는 클래스 대신 숫자 ID로 표현합니다. 개별 컬렉션에 저장한 컴포넌트가 개체 ID로 소속을 알아냅니다. 이런 개체 컴포넌트 시스템은 결합을 더욱 줄입니다. 개체 자체는 컴포넌트가 추가되는지도 모릅니다. 데이터 지역성(17장)에서 더 살펴봅니다.
기존 구체 클래스도 이름을 바꾸고 인터페이스를 구현하게 합니다.
class BjornPhysicsComponent : public PhysicsComponent { public: virtual void update(GameObject& obj, World& world) { // 물리 코드... } }; class BjornGraphicsComponent : public GraphicsComponent { public: virtual void update(GameObject& obj, Graphics& graphics) { // 그래픽스 코드... } };
C++
복사
비외른 전용 클래스 없이도 원래의 기능을 갖춘 객체를 만들 수 있습니다.
GameObject* createBjorn() { return new GameObject( new PlayerInputComponent(), new BjornPhysicsComponent(), new BjornGraphicsComponent()); }
C++
복사
다른 컴포넌트를 조합하는 생성 함수를 정의하면 게임에 필요한 여러 객체를 만들 수 있습니다.
원주 14-13: 원문은 createBjorn()을 GoF의 고전적인 팩토리 메서드 패턴이라고 설명합니다.
💡
예제 단계 연결하기: Bjorn을 GameObject로 바꿀 때는 앞의 입력 인터페이스와 PlayerInputComponent, DemoInputComponent에 있는 매개변수도 GameObject&로 함께 바꿔야 합니다. 원문은 그 반복 코드를 다시 싣지 않습니다. 또 이 update()는 세 포인터가 모두 유효하다고 가정하므로, 그래픽이 없는 존 같은 선택적 조합에는 빈 구현 컴포넌트를 제공하거나 해당 포인터의 존재를 검사해야 합니다. 이 장의 객체 조합과 ECS의 모든 저장·실행 규칙을 같은 것으로 볼 필요는 없습니다. 데이터 배치는 별도로 결정합니다.

14.7 디자인 결정

가장 중요한 질문은 ‘어떤 컴포넌트 집합이 필요한가?’입니다. 게임 장르와 요구에 따라 달라집니다. 코드가 크고 복잡할수록 컴포넌트를 세분화해야 할 것입니다. 이 밖에도 고려할 사항이 있습니다.
객체가 컴포넌트를 어떻게 얻을까?
큰 클래스를 여러 컴포넌트로 나누었다면, 누가 다시 하나로 묶을지 정해야 합니다.
객체가 필요한 컴포넌트를 직접 생성한다면:
•
항상 필요한 컴포넌트를 갖습니다. 컨테이너가 모두 처리하므로 잘못된 부품을 연결하거나 연결 자체를 잊어 게임이 깨지는 일을 피하기 쉽습니다.
•
객체를 변경하기 어렵습니다. 컴포넌트 재조합으로 새로운 종류를 만드는 것이 장점인데, 사용할 부품을 하드코딩하면 그 유연성을 잃습니다.
외부에서 컴포넌트를 제공한다면:
•
훨씬 유연해집니다. 부품만 바꾸어 전혀 다르게 동작하는 객체를 만들 수 있습니다. 극단적으로는 일반적인 컴포넌트 컨테이너가 되어 여러 용도로 재사용합니다.
•
구체 컴포넌트 타입과 분리할 수 있습니다. 외부에서 전달한다면 인터페이스를 상속한 객체를 받을 가능성이 높습니다. 컨테이너는 인터페이스만 알고 실제 구체 클래스는 모르므로 구조를 캡슐화하기 좋습니다.
컴포넌트끼리 어떻게 통신할까?
완전히 격리되어 동작하면 이상적이지만 실제로는 어렵습니다. 같은 객체를 이루는 부분들이므로 조정, 곧 통신이 필요합니다.
아래 방식은 서로 배타적인 대안이 아닙니다. 보통 여러 방식을 함께 지원하게 됩니다.
컨테이너의 상태를 변경하는 방식:
•
컴포넌트끼리 분리된 상태를 유지합니다. 입력이 비외른의 속도를 변경하고 물리가 이를 읽으면 둘은 서로를 몰라도 됩니다. 속도가 어딘가에서 바뀌었다는 사실만 알면 됩니다.
•
공유하는 정보를 컨테이너에 모두 넣어야 합니다. 일부만 사용하는 상태도 있습니다. 애니메이션과 렌더링만 공유하는 그래픽 상태를 모두 접근 가능한 컨테이너에 올리면 클래스가 지저분해집니다. 조합에 따라서는 아예 사용하지 않을 수도 있습니다. 보이지 않는 객체의 렌더링 데이터는 메모리 낭비입니다.
•
암묵적 통신이라 실행 순서에 의존합니다. 원래 통짜 메서드는 입력이 속도를 바꾸고, 물리가 위치를 바꾸고, 렌더링이 새 위치를 읽는 순서를 지켰습니다. 분리한 뒤에도 유지해야 합니다. 그래픽부터 갱신하면 이번 프레임이 아니라 이전 프레임 위치에 그립니다. 컴포넌트와 코드가 늘수록 이런 미묘한 버그를 피하기 어렵습니다.
원주 14-14: 여러 코드가 같은 데이터를 읽고 쓰는 구조에서는 올바른 코드를 작성하기 어렵습니다. 그래서 학계에서는 하스켈처럼 변경 가능한 상태를 두지 않는 순수 함수형 언어를 연구합니다.
서로 직접 참조하는 방식:
통신이 필요한 컴포넌트가 컨테이너를 거치지 않고 서로를 참조하게 합니다. 비외른에게 점프를 추가한다고 해보겠습니다. 그래픽은 점프 스프라이트를 그릴지 결정하려고 물리 쪽에 주인공이 땅에 서 있는지 물어야 합니다. 물리 컴포넌트를 직접 알고 있다면 쉽게 해결됩니다.
class BjornGraphicsComponent { public: BjornGraphicsComponent(BjornPhysicsComponent* physics) : physics_(physics) {} void Update(GameObject& obj, Graphics& graphics) { Sprite* sprite; if (!physics_->isOnGround()) { sprite = &spriteJump_; } else { // 나머지 그래픽스 코드... } graphics.draw(*sprite, obj.x, obj.y); } private: BjornPhysicsComponent* physics_; Sprite spriteStand_; Sprite spriteWalkLeft_; Sprite spriteWalkRight_; Sprite spriteJump_; };
C++
복사
그래픽 컴포넌트를 만들 때 대응하는 물리 컴포넌트를 생성자 인수로 전달하여 참조하게 합니다.
•
간단하고 빠릅니다. 다른 객체의 메서드를 직접 호출합니다. 참조하는 컴포넌트가 제공하는 어떤 메서드든 제한 없이 사용할 수 있습니다.
•
두 컴포넌트가 강하게 결합합니다. 제한이 없어서 생기는 단점입니다. 처음 구조로 일부 돌아간 셈이지만, 통신하는 쌍 사이로 결합을 제한하므로 통짜 클래스보다는 낫습니다.
💡
원본 코드의 생략 부분: 위 코드는 원문처럼 대문자로 시작하는 Update()를 사용합니다. 앞의 인터페이스와 통합하려면 소문자 update()로 맞추고 override로 확인하세요. 또 else의 생략된 그래픽 코드에서 반드시 sprite를 지정해야 합니다. 그렇지 않으면 지상 상태에서 초기화하지 않은 포인터를 역참조합니다. 코드의 핵심은 그래픽이 물리의 isOnGround()에 직접 의존한다는 점입니다.
메시지를 전달하는 방식:
가장 복잡한 대안입니다. 컨테이너에 간단한 메시징 시스템을 두고 각 컴포넌트가 다른 컴포넌트에 정보를 방송(broadcast)하게 합니다.
먼저 모든 컴포넌트가 상속할 공통 인터페이스를 정의합니다.
class Component { public: virtual ~Component() {} virtual void receive(int message) = 0; };
C++
복사
들어오는 메시지를 받는 receive()만 있습니다. 지금은 정수로 메시지를 구분하지만 실제 구현에는 추가 데이터도 보낼 수 있어야 할 것입니다.
컨테이너에는 메시지를 보내는 메서드를 추가합니다.
class ContainerObject { public: void send(int message) { for (int i = 0; i < MAX_COMPONENTS; i++) { if (components_[i] != NULL) { components_[i]->receive(message); } } } private: static const int MAX_COMPONENTS = 10; Component* components_[MAX_COMPONENTS]; };
C++
복사
컴포넌트가 컨테이너에 메시지를 보내면 컨테이너는 자신이 가진 모든 컴포넌트에 전파합니다. 처음 보낸 컴포넌트도 받으므로 피드백 루프에 빠지지 않도록 주의해야 합니다. 이 방식에는 다음 이점이 있습니다.
•
하위 컴포넌트가 서로 분리됩니다. 공유 상태 방식처럼 컨테이너를 통해 통신합니다. 메시지 값에는 의존하지만 다른 컴포넌트 자체에는 의존하지 않습니다.
•
컨테이너가 단순합니다. 상태를 공유할 때는 데이터를 소유하고 컴포넌트의 사용 방식도 알아야 했습니다. 여기에서는 메시지만 전달하면 됩니다. 특정 분야의 정보를 컨테이너에 노출하지 않고도 컴포넌트끼리 주고받습니다.
원주 14-15 · 옮긴이: 피드백 루프는 이벤트 큐(15장)에서 더 자세히 다룹니다.
원주 14-16: 메시지를 나중에 전달하도록 큐에 넣을 수도 있습니다. 이 역시 이벤트 큐(15장)에서 다룹니다.
원주 14-17: GoF에서 둘 이상의 객체가 중간 객체를 통해 간접적으로 메시지를 주고받는 방식은 중재자(mediator) 패턴입니다. 이 예제의 컨테이너가 중재자입니다.
💡
메시지 전달의 실제 구현: 이 예제는 즉시 동기적으로 receive()를 호출합니다. 메시지를 쓴다고 자동으로 시간까지 분리되지는 않습니다. components_의 빈 칸은 생성할 때 NULL 또는 nullptr로 초기화해야 하며, 전달 도중 컴포넌트를 제거할 수 있다면 순회와 수명 관리 규칙도 정해야 합니다. 같은 메시지를 받은 컴포넌트가 다시 방송하면 재귀 루프가 될 수 있으므로 재전파 조건을 제한합니다.
예상했듯 세 방식 중 유일한 정답은 없습니다. 보통 모두 조금씩 사용합니다. 상태 공유는 위치와 크기처럼 모든 객체에 있다고 기대하는 기본 정보를 나누기 좋습니다.
다른 분야여도 밀접하게 연관된 쌍이 있습니다. 애니메이션과 렌더링, 사용자 입력과 AI, 물리와 충돌이 그렇습니다. 서로 다른 컴포넌트로 나누었다면 짝을 직접 알게 하는 것이 가장 편할 수 있습니다.
메시징은 호출한 뒤 결과를 기다릴 필요가 없는 사소한 통신에 좋습니다. 물리가 충돌 사실을 알리면 오디오가 받아 소리를 내는 식입니다.
언제나 그렇듯 쉬운 방식부터 시작하고 필요할 때 다른 통신 수단을 추가하세요.

14.8 관련 자료

다음은 원문에서 든 적용 사례와 관련 패턴입니다.
•
유니티의 핵심 GameObject는 컴포넌트 방식을 중심으로 설계되었습니다.
•
오픈소스 엔진 Delta3D에는 ActorComponent 상위 클래스를 이용해 컴포넌트 패턴을 구현하는 GameActor 상위 클래스가 있습니다.
•
마이크로소프트 XNA의 핵심 Game에는 GameComponent 객체 컬렉션이 있습니다. 이 장은 개별 게임 개체 수준에서, XNA는 메인 게임 객체 수준에서 컴포넌트를 적용했다는 차이가 있을 뿐 목적은 같습니다.
•
GoF의 전략 패턴과 비슷한 점이 있습니다. 둘 다 객체의 동작 일부를 별도 하위 객체에 위임합니다. 다만 전략 객체는 대개 상태가 없습니다(stateless). 알고리즘은 있지만 데이터는 없으며, 객체가 ‘어떻게 동작하는가’를 정의하지만 ‘무엇인가’를 정의하지는 않습니다.
컴포넌트는 객체를 표현하는 상태를 가지고 정체성을 정의하는 경우도 많으므로, 말하자면 좀 더 자부심이 있습니다. 그렇다고 두 패턴의 경계가 명확한 것은 아닙니다. 내부 상태가 필요 없는 컴포넌트 하나를 여러 컨테이너가 공유하면 전략 패턴과 다를 것이 없습니다.