게임 개발에서 흔히 사용하는 객체 지향적 C++ 스타일이 현대 하드웨어와 어떻게 맞지 않으며, 가상 함수, 상속, 캡슐화, 인스턴스 중심 사고, 모듈화와 재사용성에 어떤 성능·설계상의 해를 끼치는지 데이터 지향 관점에서 분석한다.
온라인 공개판 Data-Oriented Design :
이 문서는 무료 온라인 축약판입니다. 교육용 자료라는 취지에 맞추어, 일부 중요하지 않은 장들은 제외되었지만, 데이터 지향 설계를 배우고자 하는 이들에게 필요한 핵심 내용은 모두 포함되어 있습니다.
이 문서는 자동 생성되었고, LaTeX를 HTML로 변환하는 도구들이 완벽하지 않으므로, 다소 깨진 포매팅이나 이미지, 코드 리스트가 있을 수 있습니다. 소스 코드 리스트가 깨져 있다면, 인용된 소스는 GitHub에서 찾을 수 있습니다. 이 글이 마음에 든다면, 여기에서 종이책을 구매하는 것을 고려해 주세요. 종이책은 훨씬 보기 좋을 뿐만 아니라, 이 온라인 버전을, 책을 살 형편이 되지 않는 이들을 위해 계속 유지하는 데에도 도움이 됩니다. 피드백은 언제든지 support@dataorienteddesign.com으로 보내 주세요.
하위 절
객체 지향 설계에는 무엇이 잘못된 걸까? 그게 대체 어디가 해로운 걸까?
수년 간, 게임 개발자들은 하드웨어 입장에서 보면 너무나 매력 없어서, 관리형 언어조차도 그에 비해 그리 느리지 않은 수준의 C++ 스타일에 빠져 있었다. 게임 개발에서 흔히 사용된 C++의 활용 패턴은 플레이스테이션 3, 엑스박스 360 세대의 하드웨어와 충격적일 만큼 안 맞았고, 이 때문에 인터프리터 언어가 보통 사용에서는 약 50% 정도 느린 수준이거나, 경우에 따라서는 특정 영역에서 더 빠르게 느껴지는 것11.1도 전혀 이상하지 않다. 그렇다면 C++ 게임 개발자들의 뇌 속에 박혀 버린 이 기묘한 언어는 무엇인가? 왜 유행하는 게임 코딩 방식이 우리가 겨냥하는 기계를 활용하는 최악의 방법 중 하나가 되어 버렸을까? 본질적으로, 게임 개발 스타일의 객체 지향 C++이 주는 해악은 어디에 있는가?
이 문제의 일부는 객체 지향의 의미에 대한 초기 해석에서 비롯되었다. 게임 개발자들은 객체 지향이란 자신이 신경 쓰는 모든 것의 인스턴스를 코드 속 객체 인스턴스로 그대로 대응시켜야 한다고 여기는 경향이 있었다. 이런 형태의 객체 지향 개발은 인스턴스 지향 개발(instance-oriented development)로 해석될 수 있는데, 이는 프로그램 전체보다 개별적인 단일 객체를 우선시한다. 이렇게 말해 보면, 여기서 어떤 문제들이 생길 수 있는지 더 쉽게 보인다. 개별 객체의 성능을 두고 느리다고 비판하기는 매우 어렵다. 객체 메서드는 정확히 시간을 재기도 어렵고, 애초에 시간을 재지 않는 경우가 대부분이기 때문이다. 개발 방식이 프로그램 전체보다 개별 요소를 우선시하게 되면, 모든 동작을 값(value) 의미가 아니라, 숨겨진 상태를 가진 액터들의 관점에서 생각해야 하므로, 정신적 용량에 대한 대가도 치러야 한다.
또 다른 문제는, 언어 설계자들이 성능을 무시했다기보다 성능을 ‘고립된 상태에서’만 테스트했을 가능성이 있다는 점이다. 이는 실제 C++ 사용 방식이 라이브러리 제공자가 예상한 것과 상당히 다르거나, 혹은 라이브러리 제공자들이 사용자 이해보다는 내부 지표에 맞추어 개발했기 때문일 수 있다. 저자의 의견으로는, C++에서 사용할 라이브러리나 템플릿 세트를 개발할 때, 사용자가 ‘성능을 튜닝할 수 있게만’ 만들어 두면 안 되고, 성능이 기본(default)으로 따라와야 한다. 성능을 튜닝할 수 있게 만들면, 기능과 이해 가능성, 성능을 맞바꾸는 셈이 된다. 이는 게임 개발자에게 나쁜 거래지만, 공통 언어가 주는 이득이 워낙 매력적이기에 지금까지 받아들여져 왔다.
주장: 가상 함수(virtual)는 호출 비용이 그리 크지 않지만, 아주 많이 호출하면 결국 커진다.
일명, "종이 한 장 한 장에 베이는 죽음"(death by a thousand paper cuts)
단순히 들여다보면, 가상 호출의 오버헤드는 무시할 수준이다. 가상 호출 안에서 실제로 수행하는 일에 비하면, 추가로 한 번 더 역참조하는 비용은 사소해 보이며, 가상 함수 테이블 포인터가 차지하는 메모리와 역참조 하나 이상의 눈에 띄는 부작용은 없을 것처럼 보인다. 이 개별 인스턴스에서 호출하고자 하는 함수의 포인터를 얻기 전에 추가로 한 번 더 역참조하는 일은 사소한 첨가처럼 보이지만, 실제로 무슨 일이 벌어지는지 조금 더 자세히 살펴보자.
기반 클래스에서 가상 메서드를 상속받은 파생 클래스는 특정한 구조를 갖는다. 어떤 클래스에든 가상 메서드를 추가하는 순간, 실행 파일에는 가상 함수 테이블이 생기고, 그 클래스의 묵시적 첫 번째 데이터 멤버로 vtable 포인터가 추가된다. 이를 피할 수 있는 방법은 거의 없다. C++ 언어 명세는 클래스의 데이터 레이아웃을 어느 정도까지는 컴파일러 재량으로 두고 있다. 이 범위 안에서, 컴파일러는 가상 메서드를 구현하기 위해 숨겨진 멤버를 추가하고, 비공개 영역에서 함수 포인터 배열을 생성할 수 있다. 다른 방식도 가능하겠지만, 대부분의 컴파일러는 가상 메서드의 함수 포인터를 저장하기 위해 가상 함수 테이블을 구현한다. 중요한 점은, 가상 호출은 운영체제 수준의 개념이 아니며, CPU 입장에서는 존재하지도 않는다는 것이다. 가상 호출은 C++ 구현 세부사항일 뿐이다.
클래스에 대해 가상 메서드를 호출하면, 어떤 코드를 실행할지 알아야 한다. 보통은 가상 함수 테이블의 어느 항목을 사용할지를 알아야 하고, 이를 위해 먼저 첫 번째 데이터 멤버를 읽어서, 호출할 때 사용할 올바른 가상 함수 테이블을 가져온다. 이는 클래스의 주소에서 값을 레지스터로 로드하고, 거기에 오프셋을 더하는 작업을 요구한다. 사소하지 않은 모든 가상 메서드 호출은 테이블 조회이며, 따라서 컴파일된 코드에서 모든 가상 호출은 실제로는 함수 포인터 배열의 역참조이다. 여기서 오프셋은 그 배열에서의 위치를 의미한다. 이렇게 실제 함수 포인터의 주소가 계산된 후에야 비로소 명령어 디코딩을 시작할 수 있다.
C++11에서 도입된 final 키워드를 이용해 가상 테이블을 통하지 않고 호출할 수 있는 여지도 생기긴 했다. 더 이상 상속될 수 없는 클래스는 자기 자신 안에서의 호출에 대해, 직접 함수를 호출해도 된다는 걸 알 수 있기 때문이다. 하지만 이는 다형적 호출, 즉 구체 타입을 모르는 인터페이스 관점에서 메서드를 호출하는 사용처(코드 예제
참고)에는 도움이 되지 않는다. 가끔 pImpl(private implementation) 패턴이나 CRTP(curiously recurring template pattern) 같은 일부 관용구에 도움이 될 수 있을 뿐이다.
![이미지 2: \begin{linespread}{0.75}\lstinputlisting[language=C,caption={간단한 파생 클래스},label=src:derived]{src/HARM_basederived.cpp}\end{linespread}](https://www.dataorienteddesign.com/dodbook/img62.png)
다중 상속에서는 이것이 약간 더 복잡해지지만, 기본적으로 여전히 가상 함수 테이블을 쓰며, 각 함수가 어떤 클래스의 vtable을 참조하는지 정의한다는 점만 다르다.
이제 이 메서드 호출에 실제로 포함되는 연산들을 세어 보자. 먼저 로드(load), 그다음 더하기(add), 그다음 또 하나의 로드, 마지막으로 분기(branch)가 있다. 거의 모든 프로그래머에게 이는 런타임 다형성에 지불하기에는 그다지 큰 비용처럼 보이지 않는다. 호출 한 번당 네 개의 연산으로, 모든 게임 엔터티를 하나의 배열에 쑤셔 넣고, 순회하면서 업데이트하고, 렌더링하고, 충돌 상태를 수집하고, 사운드 이펙트를 발생시키는 것이 가능하다. 이건 괜찮은 거래처럼 보인다. 다만, 이 특정 명령어들이 저렴하던 시절에만 그랬다는 점을 빼면 말이다.
네 개의 명령어 중 두 개는 로드이다. 별로 비싸지 않아 보이지만, 근처 캐시에 맞지 않으면 로드는 오래 걸리고, 명령어 디코딩도 시간이 걸린다. 레지스터 값을 적절한 함수 포인터를 가리키도록 수정하는 add는 매우 값싸다11.2. 하지만 분기는 두 번째 로드가 끝날 때까지 어디로 갈지 알 수 없으므로 항상 값싼 것은 아니다. 이는 명령어 캐시 미스를 유발할 수 있다. 요약하자면, 규모가 어느 정도 있는 게임이라면, 단일 가상 호출 하나 때문에 시간 덩어리가 날아가는 모습을 흔히 볼 수 있다. 그 시간 동안, 부동소수점 유닛만 따로 놓고 보아도 순진하게(dot product 여러 개나 제곱근들을) 계산해 끝낼 수 있을 것이다.
최선의 경우, 가상 테이블 포인터는 이미 메모리에 있고, 객체 타입도 지난번과 같아서 함수 포인터 주소도 동일하며, 따라서 함수 포인터도 캐시에 있다. 이 경우, 분기가 스톨되지 않을 가능성이 크다. 명령어 역시 아직 캐시에 남아 있을 것이기 때문이다. 하지만 이 최선의 경우가 모든 데이터 유형에 대해 항상 공통적인 경우는 아니다.
대안을 생각해 보자. 함수가 끝나고 값을 반환한 후, 다른 함수를 호출한다고 하자. 명령어의 순서는 비교적 잘 알려져 있고, CPU 입장에서는 거의 직선처럼 보인다. 프로그램 카운터를 함수마다 차례로 따라가면서 명령어를 가져오는 데 아무런 굴절이 없다. 새로 호출될 함수들의 주소는 데이터에 의존하지 않기 때문에, 상당히 멀리 앞선 위치까지 미리 추측할 수 있다. 함수 호출이 아무리 많더라도, 컴파일 타임에 전부 알 수 있다는 사실 덕분에 미리 프리패치하고, 미리 변환(pretranslate)하기 쉽다.
C++ 구현은 우리가 객체를 순회(iterate)하는 방식과 잘 맞지 않는다. 서로 다른 타입의 객체 집합을 순회하는 표준적인 방법은 말 그대로 그렇게 하는 것이다. 반복자(iterator)를 하나 잡고, 각 객체마다 가상 함수를 하나씩 호출하는 방식이다. 일반적인 게임 코드에서 이는 모든 객체마다 가상 테이블 포인터를 매번 로드하는 것을 의미한다. 이는 캐시 라인을 불러오는 동안 기다림을 유발하고, 이를 쉽게 피할 수 없다. 일단 vtable 포인터를 로드하고 나면, 상수 오프셋(가상 메서드의 인덱스)을 사용하여 호출할 함수 포인터를 찾을 수 있다. 그러나 게임 개발에서 흔히 볼 수 있는 거대한 가상 함수들 때문에, 이 테이블 자체는 캐시에 없을 공산이 크다. 당연히, 이는 또 다른 로드 대기를 야기하고, 이 로드가 끝난 후에도, 객체 타입이 직전 요소와 같기를 바랄 수밖에 없다. 그렇지 않다면, 명령어를 다시 로드하기 위해 또다시 기다려야 한다.
로드조차 없다고 해도, 어떤 함수를 호출할지를 데이터가 로드되기 전까지 알 수 없다는 사실 자체가, 올바른 명령어를 디코딩하고 있다는 확신을 얻기 위해 하나의 캐시 라인에 의존하게 만든다.
게임에서 가상 함수가 거대한 이유는, 게임 개발자들이 “타이트 루프에서는 가상 함수를 쓰지 않으면 괜찮다”고 수도 없이 주입받았기 때문이다. 이 말은 필연적으로, 가상 함수가 더 추상적인 구조 수준, 예를 들어 객체 타입의 계층 구조, 혹은 트리 형태의 문제 해결 시스템(경로 탐색, 행동 트리 같은)에서 쓰이도록 이끈다.
다시 정리해 보자. 많은 개발자들은 이제 가상 함수를 사용하는 최선의 방법이, 가상 메서드 본문 안에 많은 일을 집어넣어 가상 호출 메커니즘의 오버헤드를 상쇄하는 것이라고 믿는다11.3. 그러나 이렇게 하면, update()를 한 번 호출할 때마다 명령어 및 데이터 캐시의 많은 부분이 축출(evict)될 가능성이 매우 높을 뿐 아니라, 분기 예측기(branch predictor) 슬롯들 역시 대부분 더러워져 더 이상 이득을 제공하지 못하게 될 가능성이 커진다. 가상 호출이 고수준 코드에서만 사용되니 누적되어도 괜찮다고 가정하는 것은, 그 방식이 일반적인 프로그래밍 스타일이 되지 않았을 때에만 옳다. 이 방식이 일반화되면, 개발자들은 프로그램 전체에 어떤 영향을 미치는지 생각하지 않게 되고, 결국 초당 수백만 번의 가상 호출이 발생한다. 이 모든 비효율적인 호출은 결국 하드웨어에 영향을 주지만, 프로파일링 결과에 눈에 띄는 모습으로 잘 드러나지는 않는다. 문제가 없는 것이 아니라, 머신 전체 처리 과정에 얇게 펴져 있기 때문이다. 그것들은 언제나 어딘가에서 호출되는 코드 속에 숨어 있다.
Carlos Bueno의 책 Mature Optimization Handbook[#!MatureOpt!#]에서는, 눈앞에 보이는 ‘낮게 달린 열매’(low hanging fruit)만 무작정 따다 보면 실제 느려진 원인을 놓치기 얼마나 쉬운지를 이야기한다. 여기서 가설을 세우는 접근이 유용해질 수 있다. 기대했던 성과를 내지 못했을 때, 더 빠르게 되짚고 재정비할 수 있기 때문이다. 페이스북에서는, 캐시 축출을 유발하는 원인을 추적하고, 그 함수들을 단순히 빠르게 만들기 위해서가 아니라, 가능한 한 캐시에서 다른 데이터를 쫓아내지 않도록 최적화했다.
C++에서는 클래스별로 vtable이 생성되고 함수 포인터들이 그 안에 저장된다. 대안으로, 함수별로 vtable을 두고, 호출하는 클래스의 타입에 따라 함수 포인터를 전환(switch)하는 방법도 있다. 이는 실제로 잘 작동하며, 어느 정도 오버헤드를 줄여 주기도 한다. 한 번의 객체 그룹 순회(iteration) 동안에는 모든 호출에 같은 가상 함수 테이블을 공유하기 때문이다. 그러나 C++는 런타임에 다른 라이브러리와 링크하는 것을 허용하도록 설계되었다. 기존 코드베이스를 상속하는 새로운 클래스를 가진 라이브러리들이 링크될 수 있어야 했고, 실행 중인 원본 코드에서 이 새로운 가상 메서드들을 호출할 수 있어야 했다. 만약 C++가 함수 지향적인 가상 함수 테이블 방식을 택했다면, 새 라이브러리가 링크될 때마다(정적 링크든, 동적 라이브러리든) 가상 함수 테이블을 런타임에 패치해야 했을 것이다. 반대로, 지금처럼 클래스별 가상 함수 테이블을 사용하는 설계는, 같은 기능을 제공하면서도 링크 타임이나 런타임에 테이블을 수정할 필요가 없다. 가상 함수 테이블이 클래스 단위로 구성되어 있고, 언어 설계 상 이 클래스들은 링크 타임 동안 불변(immutable)이기 때문이다.
가상 함수 테이블 조직 방식과 게임이 메서드를 호출하는 순서를 합쳐 보면, 목록을 매우 예측 가능한 방식으로 순회하더라도 캐시 미스는 매우 흔하다. 캐시 미스를 유발하는 것은 클래스 구현뿐이 아니다. 어떤 데이터가, 어떤 명령어가 다음에 실행될지를 결정할 때마다 이런 일이 벌어진다. 게임은 흔히 스크립트 언어를 구현하고, 이 언어들은 종종 인터프리터 방식으로 가상 머신 위에서 실행된다. 가상 머신이든 JIT 컴파일러든, 구현 방식이 어떻든 간에, 데이터가 이후에 호출될 명령어를 제어하는 부분은 항상 존재하며, 이는 분기 예측 실패(branch misprediction)를 유발한다. 일반적으로 인터프리터 언어가 느린 이유가 바로 여기에 있다. 바이트코드 인터프리터의 경우처럼 데이터를 읽어 어떤 코드를 실행할지를 결정하거나, JIT 컴파일처럼 더 빠른 코드를 생성하지만 그 나름의 문제를 일으키는 방식을 취하기 때문이다.
개발자가 C++ 내장 가상 함수/가상 테이블/this 포인터를 사용하지 않고 객체 지향 프레임워크를 구현한다고 해도, 클래스가 아니라 ‘함수별’ 가상 테이블을 사용하지 않는 이상 캐시 미스 가능성을 줄이지 못한다. 설령 개발자가 매우 조심해서 구현한다 해도, 게임 개발 특유의 접근 패턴―서로 다른 타입의 객체들로 이루어진 배열에서 개별 가상 함수를 호출하는 방식―로 객체 지향 프로그래밍을 하는 한, 내장 가상 함수에서와 같은 명령어 디코드 및 캐시 미스를 어지간히 피할 수 없다. 최선의 경우에도, 가상 호출당 CPU 상태를 데이터에 의존하여 바꾸는 횟수를 한 번 줄이는 정도에 그친다. 그래도 분기 예측 실패 가능성은 두 번이나 남는다.
이렇게 분명한 비효율이 존재하는데도, 무엇이 게임 개발자들로 하여금 객체 지향 코딩 관행에 집착하게 만드는가? 게임 개발자들은 종종 최첨단 소프트웨어 개발의 선구자로 언급되는데, 왜 문제에서 완전히 벗어나 객체 지향 개발 관행을 통째로 버리는 방향으로 움직이지 않았을까?
주장: 객체는 현실 세계의 문제 기술에서 최종 코드 해법으로 더 좋은 매핑을 제공한다.
게임 프로그래밍에서 객체 지향 설계는, 게임 디자인을 엔터티 기준으로 생각하는 것에서 출발한다. 게임 디자인에 나오는 각 엔터티는 ship, player, bullet, score 같은 클래스로 대응된다. 각 객체는 자신의 상태를 유지하고, 메서드를 통해 다른 객체와 통신하며, 캡슐화를 제공해, 특정 엔터티의 구현이 변해도 이를 사용하는 다른 객체나 유틸리티를 제공하는 객체들은 변경할 필요가 없도록 만든다. 게임 개발자들은 추상화를 좋아하는데, 역사적으로 하나의 타깃 플랫폼이 아니라 최소 두 개 이상의 플랫폼을 대상으로 게임을 만들어야 했기 때문이다. 과거에는 주로 콘솔 제조사 간의 차이였지만, 지금은 Windows™와 콘솔 플랫폼을 오가며, 거기에 모바일까지 추가로 관리해야 한다. 과거의 추상화는 주로 하드웨어 접근 추상화였고, 자연스럽게 일부 게임플레이 추상화도 있었다. 게임 개발 산업이 성숙해져 가며 물리, AI, 플레이어 조작 같은 영역에서 공통적인 추상화 형태를 찾게 되었다. 이러한 공통 추상화 덕분에 서드파티 라이브러리가 생겨났고, 이들 역시 객체 지향 설계를 사용하는 경우가 많다. 라이브러리들이 게임과 상호작용하는 방식으로 에이전트(agent)라는 객체를 제공하는 것은 매우 흔하다. 이 에이전트 객체들은 숨겨져 있든 공용이든, 자신의 상태 데이터를 가지고 있으며, 주어진 시스템의 제약 범위 내에서 조작될 수 있는 함수들을 제공한다.
게임 디자인에서 영감을 받은 객체들(예: ship, player, level)은 이러한 에이전트를 쥐고 자신의 세계에서 무슨 일이 벌어지는지 알아내는 데 사용한다. 플레이어는 물리, 입력, 애니메이션, 다른 엔터티들과 상호작용하고, 이를 객체 지향 API를 통해 수행함으로써, 이러한 다양한 작업에 실제로 어떤 것이 필요한지에 대한 세부 사항을 감춘다.
객체 지향 설계에서의 엔터티는 데이터의 컨테이너이자, 그 데이터를 조작하는 모든 함수가 위치하는 장소이다. 이런 엔터티를 엔터티 시스템(entity system)의 엔터티와 혼동해서는 안 된다. 객체 지향 설계에서의 엔터티는 생애 동안 클래스 타입이 불변이다. C++에서는 객체 지향 엔터티가 생애 중에 클래스를 바꾸지 않는다. 언어 안에 제자리에서(in-place) 클래스를 재구축하는 과정이 없기 때문이다. 예상할 수 있듯이, 적절한 도구가 없으면, 숙련된 작업자는 우회하는 법을 배운다. 게임 개발자들은 런타임에 객체 타입을 바꾸지 않고, 이런 기능이 필요한 게임 엔터티의 경우 새로운 객체를 생성하고 기존 것을 파괴하는 방식을 택한다. 하지만 흔히 그렇듯, 언어에 기능이 없으면, 그 기능은 설령 의미가 있는 경우에도 거의 사용되지 않는다.
예를 들어, 1인칭 슈팅 게임에서 애니메이션 플레이어 메시를 표현하는 객체가 있다고 하자. 플레이어가 죽으면, 죽은 시체를 표현할 래그돌(ragdoll) 객체를 복제하여 만든다. 애니메이션 플레이어 객체는 보이지 않게 만들고 다음 스폰 지점으로 옮기는 동안, 다른 가상 함수 세트와 다른 데이터를 가진 죽은 시체 객체는 플레이어가 죽은 위치에 남아, 플레이어가 자신의 시체를 볼 수 있게 한다. 플레이어가 죽은 후, 죽은 시체 객체를 플레이어의 대체물로 앉히는 이 속임수를 구현하기 위해서는 복사 생성자를 정의해야 한다. 플레이어가 게임에 다시 스폰되면, 플레이어 모델은 다시 보이게 된다. 플레이어는 원한다면 자신의 죽은 복제체를 보러 갈 수도 있다. 이 방식은 놀라울 정도로 잘 작동하지만, 플레이어가 “죽은 래그돌이 되는” 대신 타입이 다른 복제체를 스폰하는 이런 속임수 자체가, 플레이어의 타입을 실제로 바꿀 수 있다면 필요 없었을 것이다. 여기에는 고유한 위험도 존재한다. 복제 과정에 버그가 있을 수도 있고, 다른 문제를 유발할 수도 있다. 또한, 플레이어가 죽었지만 어떻게든 부활할 수 있게 되었을 경우, 래그돌을 다시 애니메이션 플레이어로 되돌리는 방법을 찾는 것은 결코 쉽지 않다.
AI에서도 비슷한 예를 찾을 수 있다. 대부분의 게임 AI를 구동하는 유한 상태 머신(finite state machine)과 행동 트리(behaviour tree)는 가능한 모든 상태에 필요한 데이터를 유지한다. AI가 세 개의 상태 { Idle, Making-a-stand, Fleeing-in-terror }를 가진다고 하자. 이 경우, 세 상태에 필요한 데이터를 모두 가지고 있게 된다. 만약 Making-a-stand 상태에 공포심을 누적하는 scared-points가 있어, 싸우되 너무 겁이 나면 도망치는 식의 동작을 한다면, Fleeing-in-terror 상태에는 도망치는 시간을 제한하는 타이머가 있을 수 있다. 그러면 Idle 상태에도 이 불필요한 두 속성이 존재하게 된다. 이 사소한 예에서 AI 클래스는 { state, how-scared, flee-time } 세 개의 데이터를 가지고, 이 중 어떤 멤버도 세 상태 모두에서 사용되지는 않는다. 만약 AI가 상태 전환에 따라 타입을 바꿀 수 있다면, 상태를 나타내는 멤버조차 필요 없다. 그 기능은 vtable 포인터가 대신해 줄 수 있기 때문이다. AI는 해당 상태에 있을 때에만, 그 상태를 추적하는 멤버들을 위해 공간을 할당할 것이다. C++에서 우리가 할 수 있는 최선은 vtable 포인터를 수동으로 바꾸는 것뿐이다. 위험하지만 가능하다. 또는 가능한 모든 전환마다 복사 생성자를 두는 방식으로 흉내 내야 한다.
불변 타입이라는 문제 외에도, 객체 지향 개발에는 철학적인 문제도 있다. 사람들이 실제 세계에서 객체를 인식하는 방식을 생각해 보자. 모든 관찰에는 항상 문맥(context)이 존재한다. 소박한 탁자를 예로 들어 보자. 당신이 탁자를 보면, 나무로 만든, 다리가 네 개 달리고, 적당히 광택 처리된 탁자로 보일 수 있다. 그렇다면 갈색으로 보이겠지만, 동시에 빛의 반사도 볼 것이다. 나뭇결도 볼 수 있겠지만, 색깔을 떠올리면 하나의 색으로 생각하게 된다. 하지만, 미술 교육을 받은 사람이라면, 눈으로 보는 것이 실제로 거기에 존재하는 것과 다르다는 걸 알고 있을 것이다. 단색은 존재하지 않으며, 탁자를 바라볼 때, 그 형태를 정확히 보는 것이 아니라, 단지 추론할 뿐이다. 눈에 들어오는 빛의 평균 색을 보고 갈색이라고 추론한다면, 불을 끄면 더 이상 갈색이 아니게 되는가? 빛이 너무 강해 폴리싱된 표면의 반사만 보인다면 어떨까? 한쪽 눈을 감고 긴 변 쪽에서 직사각형 형태를 보면, 직각이 아니라 사다리꼴로 보일 것이다. 우리는 자동으로 이런 것들을 보정하고, 객체를 분류한다. 그리고 이 분류를 통해 추론을 쉽게 만들기 위해 객체를 고정시킨다. 이것이 객체 지향 개발이 우리에게 매력적으로 보이는 이유다. 하지만, 인간이 소비하기 쉬운 방식은 컴퓨터에는 전혀 최적이 아니다. 게임 엔터티를 객체로 생각하면, 우리는 그것들을 전체로서 생각한다. 그러나 컴퓨터는 객체라는 개념이 없고, 객체를 단지 형편없이 조직된 데이터와, 그 데이터에 임의로 호출되는 함수들의 집합으로만 본다.
탁자의 또 다른 예를 생각해 보자. 다리 길이가 대략 3피트인 탁자를 상정하자. 이는 누군가에게는 표준적인 탁자일 것이다. 다리가 1피트 정도라면, 커피 테이블(coffee table)로 볼 수 있다. 짧지만 여전히 잡지를 쌓아 두고 컵을 올려놓기에 쓸 만하다. 하지만 다리가 1인치 길이밖에 안 되면, 더 이상 탁자라고 부르기 어렵고, 그냥 작은 돌기가 달린 커다란 나무판에 불과하게 된다. 우리는 같은 물건이라도 치수를 바꾸면 세 개의 서로 다른 객체 클래스로 기꺼이 분류한다. 탁자, 커피 테이블, 그리고 작은 나무 조각이 붙은 나무 덩어리. 그렇다면, 나무 덩어리가 커피 테이블이 되는 경계는 어디일까? 다리 길이가 4~8인치 사이일까? 이는 모래에 대한 고전적인 문제와 같다. 개별 모래 알갱이에서 모래 더미로는 언제 넘어가는가? 몇 개의 알갱이가 있어야 더미이며, 사구(dune)는 몇 개인가? 답은 “답이 없다”는 것이다. 이 답은 컴퓨터가 사고하는 방식을 이해하는 데에도 도움이 된다. 컴퓨터는 인간의 분류학적 차이를 정확히는 알지 못한다. 어떤 의미에서 인간조차도 모호하게만 알고 있기 때문이다.
객체의 클래스는 그 객체가 “무엇인지”로는 잘 정의되지 않는다. 오히려 “무엇을 하는지”로 정의된다. 이 때문에 덕 타이핑(duck typing)이 강력한 접근법이다. 타입이 무엇으로 정의되는지 생각해 보면, 다형적 타입의 본질에 다가갈수록, 다형성이란 사실 “무엇을 할 수 있는지” 관점에서만 존재한다는 것을 알게 된다. C++에서 가상 함수를 가진 클래스는 런타임 다형적 인스턴스로 호출될 수 있다는 점이 명확하다. 하지만 그 함수들이 없다면, 아예 분류할 필요조차 없었을 것이라는 점은 덜 분명할 수 있다. 다중 상속이 유용한 이유도 여기에서 나온다. 다중 상속은 단지 하나의 객체가 특정 자극에 반응할 수 있음을 의미한다. 즉, 다형적 함수 응답 계약을 충족할 수 있음을 선언한 것이다. 만약 다형성이 단지 “객체가 기능적 계약을 이행할 수 있는 능력”에 불과하다면, 매번 그 처리를 위해 가상 호출을 사용할 필요는 없다. 객체에 따라 다른 동작을 하게 만드는 다른 방법들도 존재한다.
대부분의 게임 엔진에서, 객체 지향 접근은 깊은 계층 구조에 놓인 많은 객체들을 낳는다. 한 엔터티의 흔한 조상 체인은 다음과 같을 수 있다.
PlayerEntity![]()
CharacterEntity![]()
MovingEntity![]()
PhysicalEntity![]()
Entity![]()
Serialisable![]()
ReferenceCounted![]()
Base.
이처럼 깊은 계층 구조는, 가상 메서드를 호출할 때 여러 단계의 간접 호출을 사실상 보장한다. 하지만 이는 상속 계층과 무관한 관심사(cross-cutting concerns)를 처리할 때도 큰 고통을 초래한다. 즉, 계층과 관계없거나 상충되는 관심사에 영향을 주거나 영향을 받는 코드의 문제이다.
일반적인 게임에서, 캐릭터들이 장면(scene)을 돌아다닌다고 해 보자. 장면에는 캐릭터, 월드, 파티클 이펙트, 조명(정적, 동적 모두) 등이 있을 수 있다. 이 장면에서, 이 모든 것들은 렌더링 대상이 되거나 렌더링에 사용되어야 한다. 전통적인 접근은 다중 상속을 사용하거나, 모든 엔터티의 상속 체인 어딘가에 Renderable 기반 클래스를 배치하는 것이다. 하지만 소리를 내는 엔터티는 어떨까? AudioEmitter 같은 클래스를 추가해야 할까? 직렬화되는 엔터티와, 레벨이 명시적으로 관리하는 엔터티 간의 차이는? 너무 흔해서 별도의 메모리 관리가 필요한(예: 파티클) 엔터티는? 멀리 있는 쓰레기, 꽃, 풀처럼 선택적으로만 렌더링해야 하는 엔터티는?
이 문제는, 게임 전체의 코어 기반 클래스에 가장 흔한 기능을 모두 집어넣고, 특수한 상황(예: 레벨이 애니메이션되는 경우, 플레이어 캐릭터가 인트로나 죽는 연출에 등장하는 경우, 혹은 특별 취급받아야 하는 보스 캐릭터 등)에 대해서는 예외를 두는 방식으로 수없이 “해결”되어 왔다. 다중 상속을 사용하지 않을 경우에만 이런 해킹이 필요하다. 하지만 다중 상속을 사용하면, 결국 가상 상속(virtual inheritance)과 그에 수반되는 상태 복잡성으로 이어질 수 있는 거미줄이 생겨난다. 이 타협안은 거의 예외 없이, 온 우주의 근간이 되는 거대한 베이스 클래스(cosmic base class) 안티패턴으로 귀결된다.
객체 지향 개발은 소스 코드 속에 인간 지향적인 문제 표현을 제공하는 데는 능숙하지만, 기계 지향적인 해법 표현을 제공하는 데는 서투르다. 또한 최적의 해법을 만들기 위한 프레임워크를 제공하는 데도 서툴다. 그렇다면 질문은 여전히 남는다. 왜 게임 개발자들은 아직도 객체 지향 기법을 사용해 게임을 개발하고 있을까? 이는 더 나은 설계 때문이 아니라, 코드를 더 바꾸기 쉽게 만들기 위해서일지도 모른다. 게임 개발자들은 게임 디자인이 런칭 직전까지도 자연스럽게 진화한다는 것을 잘 알고 있고, 이에 발맞춰 코드를 끊임없이 변경한다. 그렇다면, 객체 지향 개발은 유지보수와 수정 작업을 더 간단하고 안전하게 만들어 주는가?
주장: 캡슐화는 코드를 더 재사용 가능하게 만든다. 구현을 수정하더라도 사용 방식에 영향을 주지 않기 때문에, 유지보수와 리팩터링이 쉽고, 빠르고, 안전해진다.
캡슐화의 기본 아이디어는, 구현을 그대로 노출하는 대신, 코드를 사용하는 사람에게 계약(contract)을 제공하는 것이다. 이론상, 캡슐화를 잘 활용한 객체 지향 코드는, 객체가 내부 데이터를 조작하는 방식을 바꾸더라도 그로 인한 피해를 입지 않는다. 객체를 사용하는 모든 코드가 계약을 준수하고, 접근자(accessor) 함수를 통하지 않고는 데이터 멤버를 직접 사용하지 않는다면, 클래스가 그 계약을 이행하는 방식을 어떻게 바꾸든, 새로운 버그는 생기지 않는다는 이야기다. 이론적으로, 계약이 수정되지 않고(단지 확장만 된다면), 객체 구현은 어떤 식으로든 변경될 수 있다. 이는 개방-폐쇄 원칙(Open-Closed Principle)이다. 클래스는 “확장에는 열려 있고, 수정에는 닫혀 있어야” 한다.
계약은 복잡한 시스템이 어떻게 작동하는지에 대한 어떤 보증을 제공하기 위한 것이다. 실제로는, 이런 보증을 제공할 수 있는 것은 단위 테스트뿐이다.
때로는 프로그래머가 객체 구현의 숨겨진 특징에 자신도 모르게 의존한다. 때로는 그들이 의존하는 객체에 우연히 그들의 사용 사례에 딱 맞는 버그가 존재할 때도 있다. 그 버그를 고치면, 그 객체를 사용하는 코드는 더 이상 기대대로 작동하지 않는다. 계약 자체는 유지되었지만, 이를 사용한 다른 코드가 새 버전에서도 정상 작동하리라 보장하지는 못했다. 오히려, 반환 값이 바뀌지 않으리라는 헛된 희망만 제공한 셈이다. 꼭 버그일 필요도 없다. 객체 내부의 시간적 결합(temporal coupling)이나, 우발적/비공식적인 기능이 후속 버전에서 사라지면서, 계약 자체는 깨지지 않았는데도, 그 계약을 사용하는 코드가 망가질 수도 있다.
예를 들어, 내부 리스트를 항상 정렬 상태로 유지하는 구현이 있다고 하자. 어떤 사용 사례는 이를 우연히(예상치 못한 사용자 측 버그로서, 의도적인 의존이 아닌) 활용하고 있었는데, 유지보수자가 성능 향상을 위해 구현을 바꾼다. 그러면 사용자는 새로 생긴 수많은 버그만 보게 될 것이고, 아마도 성능 업데이트가 의심스럽다고 생각할 것이다. 자기 코드가 아니라.
좀 더 구체적인 예로, 이름순으로 정렬된 아이템 리스트를 유지하는 아이템 매니저가 있을 수 있다. 이때, 필터에 맞는 모든 아이템 타입을 반환하는 함수가 있다고 하자. 그러면 호출 측은 반환된 리스트를 순회하며 원하는 아이템을 찾을 수 있다. 속도를 높이기 위해, 찾고자 하는 이름보다 더 뒤에 오는 이름을 가진 아이템을 만나는 시점에 일찍 종료할 수도 있고, 반환된 리스트에서 이진 검색을 수행할 수도 있다. 두 경우 모두 내부 표현이 “이름순 정렬”에서 변경되면, 이 코드는 더 이상 작동하지 않는다. 만약 내부 표현을 해시값순으로 정렬하도록 바꾸면, 빠른 종료와 이진 검색은 완전히 깨질 것이다.
많은 연결 리스트 구현에서는, 리스트의 길이를 저장할지 말지를 두고 고민한다. 길이 카운트를 저장하면 멀티스레드 접근이 느려질 수 있지만, 저장하지 않으면 리스트 길이를 구하는 데
시간이 든다. 리스트가 비어 있는지만 알고 싶은 상황에서, 객체 계약이 get_count() 함수만 제공한다면, 카운트가 0보다 큰지를 확인하는 것이 더 싼지, begin()과 end()가 같은지 확인하는 것이 더 싼지 확신할 수 없다. 이는 계약이 제공하는 정보가 너무 적은 또 다른 예다.
캡슐화는, 버그를 숨기고, 프로그래머가 가정을 하게 만들 뿐인 것처럼 보인다. 오래된 말이 있듯, 가정(assumption)은 위험하다. 캡슐화는 소스 코드를 볼 수 없는 한, 이러한 가정을 확인하거나 부정할 방법을 주지 않는다. 만약 소스 코드에 접근할 수 있고, 무슨 일이 잘못되었는지를 알아내기 위해 그것을 봐야 한다면, 캡슐화가 한 일은 자기 자신이 유용한 기능을 추가하는 대신, 단지 우회해야 할 레이어를 더한 것뿐이다.
주장: 모든 객체를 인스턴스로 만드는 것은, 객체의 책임, 생애 주기, 객체 세계에서의 위치를 생각하기 쉽게 한다.
인스턴스 중심 사고의 첫 번째 문제는, 모든 것이 “한 개체가 무언가를 한다”는 아이디어를 중심으로 돌아가며, 이것이 바로 성능 저하로 이어지는 지름길이라는 점이다.
두 번째이자 더 널리 퍼진 문제는, 인스턴스 중심 사고가 인스턴스에 대해 추상적으로 생각하게 만들고, 완전한 객체를 사고의 기본 단위로 사용할 경우, 매우 비효율적인 알고리즘으로 이어질 수 있다는 점이다. 어떤 항목의 내부 표현을, 그것을 사용하는 프로그래머에게조차 숨겨 버리면, 그 객체에 대한 한 가지 관점을 다른 관점으로, 그리고 다시 되돌리는 번역 과정이 필요해지는 경우가 자주 있다. 어떤 항목이 다른 객체를 변경할 필요가 있지만, 그 항목이 있는 세계(context)에서는 그 객체에 도달할 수 없는 경우, 해당 항목은 컨테이너에 메시지를 보내서, 다른 엔터티에 대한 질문에 답하는 목표를 달성하도록 도와달라고 해야 한다. 안타깝게도, 이런 경로를 따라가면서, 프로그램은 데이터 요구사항을 잃어버리고, 쿼리나 응답에서 더 많은 데이터를 넘기게 되며, 필요 이상의 권한뿐 아니라, 관련된 시스템 상태 때문에 불필요한 제약까지 짊어지는 경우가 드물지 않다.
문제가 어떻게 잘못 흘러갈 수 있는지 보여 주는 예로, 시민들이 행복도(happiness)를 갖는 도시 건설 게임을 상상해 보자. 각 시민이 행복도를 갖는다면, 그 행복도를 계산해야 할 것이다. 시민 수가 엄청나게 많지 않다고 가정해 보자. 최대 건물이 1000개이고, 건물당 시민이 최대 10명이라면 어떨까. 필요할 때만 시민의 행복도를 계산하면 속도가 빨라질 것이다. 실제로 이런 숫자를 가진 어느 게임에서는, 시민 행복도를 지연 평가(lazy evaluation) 방식으로 계산했었다.
행복도를 계산하는 방식은, 개별 시민의 관점에서 계산하는지, 도시의 관점에서 계산하는지에 따라 문제가 될 수 있다. 시민이 직장과 가깝고, 지역 편의 시설과 가깝고, 공업 지대에서 멀리 떨어져 있고, 레크리에이션 구역에 쉽게 갈 수 있다면, 행복도의 상당 부분은 일종의 길찾기(pathfinding)에서 나온다. 길찾기의 결과를 캐시한다면, 같은 건물에 사는 시민들은 이득을 볼 수 있지만, 건물마다 각기 다른 건물들까지의 거리는 조금씩 다르다. 이 많은 인스턴스에 대해 길찾기를 수행하는 것은 매우 비싸다.
대신 도시 전체가 행복도를 계산한다고 해 보자. 그러면, 각 건물 유형으로부터의 거리를 플러드 필(flood fill) 패스로 계산해, 도시 전체에 대한 일반적인 거리 맵을 생성할 수 있다. 그리고 Floyd–Warshall 알고리즘을 사용해, 시민들이 자신의 직장이 얼마나 가까운지 판단할 수 있도록 도울 수 있다. 일반적으로
알고리즘을
알고리즘 대신 사용하는 것은 어리석어 보일 수 있다. 하지만 길찾기가 시민당 한 번씩 이루어진다면, 전체는
이 되어 알고리즘적으로도 우월하지 않다. 게다가 현실 세계에서는, 길찾기 자체도 다른 오버헤드를 가지고 있고, 행복도를 계산하기 전에 Floyd–Warshall 알고리즘을 사용해 조회용 맵을 만들어 두면, 행복도를 계산하는 작업은(데이터 저장 측면에서) 더 단순해지고, 지원 코드로 분기하는 횟수도 줄일 수 있다. Floyd–Warshall 알고리즘은 기존 맵을 사용하여 어느 항목을 업데이트해야 할지 표시하는 방식으로 부분 업데이트(partial update)를 수행할 수도 있다. 인스턴스 관점에서 달려들었다면, 도시 지형이나 주변 건물 타입의 변화가 있을 때마다, 인스턴스마다 어떤 형태로든 거리 계산을 해야 한다는 사실밖에 알 수 없었을 것이다.
결론적으로, 추상화는 어려운 문제를 푸는 토대가 되지만, 게임에서는 종종 복잡한 알고리즘 문제를 풀고 있지 않다. 오히려, 우리는 너무 이른 시점에 추상화를 도입하는 경향이 있고, 객체 지향 설계는 비용이 충분히 드러나기도 전에 추상화에 과감히 커밋(commit)할 수 있는 익숙하고 손쉬운 방법을 제공한다. 그러다 보면, 이미 다른 코드에 과도하게 의존하게 되어, 비용을 감수하지 않고는 이 추상화들을 걷어낼 수 없게 될 시점에야 비로소 문제가 드러난다.
주장: 상속은 확장을 통해 코드 재사용을 가능하게 한다. 새 기능 추가가 단순해진다.
상속은 C++에서 클래스를 사용하는 주요 이유 중 하나로, 게임 프로그래머들 사이에서 받아들여졌다. 명백한 이점은 물리, 애니메이션, 렌더링 같은 시스템 객체에서 특성이나 행위 능력(agency)을 얻기 위해, 여러 인터페이스로부터 상속받을 수 있다는 점이었다. C++가 처음 도입되던 시절, 계층 구조는 그리 깊지 않았다. 보통 세 단계 정도를 넘기지 않았다. 하지만 시간이 지나며, 플레이어, 차량, AI 플레이어 같은 핵심 클래스에서 아홉 단계가 넘는 조상 계층 구조를 발견하는 것이 흔해졌다.
예를 들어, Unreal Tournament에서 미니건 탄약 객체는 다음과 같은 계층을 갖는다.
Miniammo![]()
TournamentAmmo![]()
Ammo![]()
Pickup![]()
Inventory![]()
Actor![]()
Object
게임 개발자들은 상속을 사용해, 다수의 게임 엔터티를 타입 검사 코드를 수동으로 작성하지 않고도 한꺼번에 업데이트, 렌더링, 쿼리할 수 있는 견고한 런타임 다형성 메커니즘을 구현한다. 또한, 상속을 통해 클래스를 확장하면 기능이 추가되므로, 복붙을 줄이는 장점도 중요하게 여긴다. 이는 초기 혼합(mixin) 형태로, 과거에는 동일한 버그를 여러 곳 중 한 곳에서만 수정하는 바람에 발생하는 문제를 줄이는 수단으로 여겨졌다. 시간이 흐르며, 다중 상속은 점차 인터페이스 전용으로 축소되었고, 실제 동작을 담은 클래스는 하나만 상속받고, 나머지는 Java식의 순수 가상 인터페이스 클래스만 상속하는 형태가 일반적이 되었다.
클래스를 상속하여 기능을 확장하는 일이 안전해 보이지만, 메서드를 오버라이드할 때 클래스가 기대대로 동작하지 않는 상황이 많다. 클래스를 확장하려면, 상속받는 클래스뿐 아니라, 그 위에 있는 조상 클래스들의 소스까지 모두 읽어야 하는 경우가 빈번하다. 만약 기반 클래스가 순수 가상 메서드를 선언했다면, 자식 클래스는 그 메서드를 반드시 구현해야 한다. 그럴만한 충분한 이유가 있다면 강제하는 것이 맞지만, 이 강제는 각 계층의 “첫 번째로 인스턴스화 가능한 클래스”에게만 적용된다. 이 때문에, 새로운 클래스가 간혹 상속 원본과 같은 동작을 하거나, 같은 방식으로 취급되는 모호한 버그가 발생할 수 있다.
C++에서 빠져 있는 기능 중 하나는 “non-virtual”이라는 개념이다. 함수가 가상이 아님을 선언할 수 없다. 즉, 어떤 함수가 override인지 선언할 수는 있지만, override가 아님을 선언할 수는 없다. 이는 흔한 단어를 함수 이름으로 사용하고, 새 가상 메서드가 같은 시그니처의 기존 함수와 겹칠 때 문제를 일으킬 수 있다. 이 경우 버그가 생길 확률이 높다.
![이미지 20: \begin{linespread}{0.75}\lstinputlisting[language=C,caption={런타임, 컴파일 타임, 링킹 타임?},label=src:compiletime]{src/HARM_compiletime.cpp}\end{linespread}](https://www.dataorienteddesign.com/dodbook/img67.png)
C++ 상속의 또 다른 함정은 런타임과 컴파일 타임 링킹의 차이에서 온다. 좋은 예시는, 메서드 호출에서의 디폴트 인자와 잘 이해되지 않은 오버라이드 규칙이다. 코드 예제
의 프로그램 출력이 무엇일 것으로 예상하는가?
만약 그 결과가 10이라고 나온다면 놀랍지 않은가? 어떤 코드는 컴파일된 상태를 기준으로 동작하고, 어떤 코드는 런타임을 기준으로 동작한다. 클래스를 확장해 새로운 기능을 추가하는 일은 위험한 게임이 되기 쉽다. 두 단계 아래에 있는 클래스가, 상호 결합(side effect)을 일으키거나, 예외를 던지거나(혹은 더 나쁘게도, 예외를 던지지 않고 조용히 실패하거나), 당신의 변경 사항을 우회하거나, 심지어는 특정 정렬을 요구하거나 특정 메모리 뱅크에 존재해야 하는 등, 당신의 계획과 양립할 수 없는 제약을 요구해, 기능을 구현할 수 없게 만들 수도 있다.
상속은 런타임 다형성을 구현하는 깔끔한 방법을 제공하지만, 앞에서 본 것처럼 유일한 방법은 아니다. 상속을 통해 새 기능을 추가하려면, 기반 클래스로 돌아가, 디폴트 구현을 제공하거나, 순수 가상 함수로 만들고, 그 기능을 처리해야 하는 모든 클래스에 구현을 추가해야 한다. 이는 기반 클래스를 수정해야 하고, 순수 가상 방식을 택했다면 모든 자식 클래스에 손을 대야 한다. 컴파일러가 코드를 변경해야 하는 위치를 찾는 데 도움을 주긴 하지만, 코드 변경을 그리 크게 더 쉽게 만들지는 못한다.
가상 함수 테이블 포인터 대신 타입 멤버를 사용하면, 동일한 런타임 코드 링킹을 제공하면서도, 캐시 미스 측면에서 더 나을 수 있고, 새 기능을 추가하고 그것에 대해 reasoning하기도 더 쉬울 수 있다. 구현 부담이 적고, 상속에 비해 기능들을 혼합 조합하기 쉽고, 다형 코드가 한 곳에 모이기 때문이다.
예를 들어, 가상 함수를 흉내낸 go_forward라는 가짜 가상 함수가 있다고 하자. Car 클래스에서 이 함수는 가속 페달을 밟게 만들 것이다. Person 클래스에서는 방향 벡터를 설정할 것이다. UFO 클래스에서도 방향 벡터를 설정할 것이다. 이는 전형적인 switch 문의 fall-through가 어울리는 상황이다. 또 다른 가짜 가상 함수 re_fuel에서는, Car와 UFO는 재급유 타이머를 시작하고, 연료 보충 애니메이션이 재생되는 동안 제자리에 멈춘 채 있을 것이다. 반면 Person 클래스는 스태미나 포션 개수를 줄이고, 즉시 “연료 보충”을 마칠 수 있다. 이 경우에도, fall-through를 활용한 switch 문만으로 필요한 모든 런타임 다형성을 구현할 수 있다. 여기에 각 클래스, 각 함수 수준에서 서로 다른 기능을 제공하기 위해 다중 상속을 사용할 필요가 없다. 각 메서드가 클래스마다 무엇을 할지를 결정할 수 있는 능력은 상속이 잘 못하는 부분이지만, 원하는 기능이다. 상속 없는 다형성은 이를 가능하게 한다.
상속을 사용하는 원래 이유는, 기반 클래스를 다시 방문하거나 기존 코드를 변경하지 않고도, 코드베이스에 기능을 확장하고 추가할 수 있기 때문이었다. 그러나 실제로는, 최소한 기반 클래스 구현을 살펴봐야 할 가능성이 높고, 게임의 사양이 변함에 따라, 기반 클래스 수준의 변경이 자주 필요하다. 또한, 상속은 개발자들이 객체를 게임 내 다른 객체 타입과의 IS-A 관계로만 생각하게 만들어, 특정 유형의 분석을 방해한다. 객체를 특징(feature)들의 조합으로 사고할 수 없게 되면서, 유연성이 크게 감소한다. 다중 상속을 인터페이스 수준으로 제한하면, 코드 복잡성을 줄이는 데에는 도움이 되지만, 복합 객체를 구축하는 좋은 수단이었던 유일한 방법에 안개가 드리워지게 된다. 물론, 이 방식 자체도 캐시를 학대한다는 점에서 좋은 해법은 아니지만, 타입에 대한 switch는 가상 테이블과 비슷한 기능을, 그 부수적인 짐 없이 제공해 주는 것처럼 보인다. 그렇다면, 왜 모든 것을 클래스에 밀어 넣는가?
주장: 결합도를 줄이고 테스트를 개선하기 위한 모듈형 아키텍처
객체 지향 패러다임은 코드 품질을 보장하기 위한 또 다른 도구로 여겨진다. 개방-폐쇄 원칙을 엄격히 따르고, 항상 접근자/메서드/상속을 통해 객체를 사용하거나 확장함으로써, 프로그래머들은 순수 절차형 관점으로만 프로그래밍할 때보다 훨씬 더 모듈화된 코드를 작성한다고 믿는다. 이러한 모듈성은 각 객체의 코드를 하나의 단위로 분리한다. 이 단위는 데이터와 그 데이터를 조작하는 모든 메서드들의 집합이다. 객체 단위로 테스트하는 일이 더 단순하다는 이야기도 많이 들어봤을 것이다. 각 객체를 고립된 상태에서 테스트할 수 있기 때문이다.
그러나 실제로는, 목적에 의해 데이터가 서로 연결되고, 데이터에 의해 목적이 다시 연결되는 긴 우발적 관계 체인이 존재하기 때문에, 이 말은 참이 아님을 알고 있다.
객체 지향 설계는 의사소통 오류 문제를 겪는다. 객체는 시스템이 아니며, 시스템은 테스트되어야 한다. 시스템은 객체뿐 아니라, 그 사이의 내재된 의사소통도 포함한다. 객체 간 통신은, 실제로는 클래스를 완전히 고립시키기가 어렵기 때문에, 테스트하기가 까다롭다. 객체 지향 개발은 시스템을 객체 지향적 관점으로 바라보게 만들며, 이 때문에 데이터 변환, 의사소통, 시간적 결합과 같은 비-객체 요소를 고립시키기 어렵게 만든다.
모듈형 아키텍처는, 변경으로 인한 잠재적 피해를 제한해 주기 때문에 좋은 것이다. 하지만 캡슐화의 경우와 마찬가지로, 모듈의 계약은 명확해야 하며, 구현의 의도치 않은 부작용에 외부 코드가 의존할 가능성을 줄여야 한다.
객체 지향 모듈 접근이 그리 잘 동작하지 않는 이유는, 모듈이 “객체 경계”에 의해 정의되기 때문이지, 더 높은 수준의 개념에 의해 정의되기 때문이 아니다. 좋은 모듈성의 예로는, stdio의 FILE, CRT의 malloc/free, NvTriStrip 라이브러리의 GenerateStrips 등이 있다. 이들 각각은, 그렇지 않으면 압도적이거나 이해하기 어려울 기능 집합에 대해, 견고하고, 문서화된, 폭이 좁은 함수 집합을 제공한다.
객체 지향 개발에서의 모듈성은, 코드를 이해하지 못하는 다른 프로그래머들로부터 보호를 제공할 수 있다. 하지만 코드를 이해하지 못하는 프로그래머가, 단순화된 인터페이스라고 해서 그 코드를 안전하게 사용할 수 있을까? 어떤 객체의 메서드는, 새로 온 사람의 눈에는 해당 객체의 사용 설명서로 보이기 쉽다. 따라서 중요한 조작 메서드를 한 블록에 모아 두는 것은, 이 클래스를 사용하는 사람에게 단서를 줄 수 있다. 여기서 모듈성이 중요한 이유는, 게임 개발에서의 객체가 흔히 매우 크고, 많은 서로 다른 측면들에 걸친 풍부한 기능을 제공하기 때문이다. 횡단 관심사(cross-cutting concerns)를 다룰 방법을 찾는 대신, 게임 객체는 대개 원래 설계를 넘어 모든 요구사항을 떠안는다. 이러한 비대화(bloat) 때문에, 모듈 방식―관심사별로 메서드를 코드에서 묶어 두는 방식―이, 새로 객체를 접하는 프로그래머에게는 도움이 될 수 있다. 이 문제를 근본적으로 해결하는 명백한 방법은, 횡단 관심사를 더 근본적인 수준에서 지원하는 패러다임을 사용하는 것이다. 그러나 C++의 객체 지향 개발은 코드 안에서 이를 표현하는 데 비효율적인 것처럼 보인다.
객체 지향 개발이, 명시적으로 코드를 모듈화하는 것 이상으로 더 나은 결과를 낳을 만큼 모듈성을 향상시키지 못한다면, 객체 지향이 제공하는 것은 무엇인가?
주장: 범용 코드 재사용을 통한 개발 시간 단축
개발에서 일관되게 오버헤드를 줄이기 위해 기존 코드를 재사용하는 능력은, 성배에 비유될 만큼 중요한 목표로 여겨진다. 투자된 시간과 노력을 허비하지 않기 위해, 기존 코드로 애플리케이션을 조합하고, 소수의 새로운 기능만 직접 작성할 수 있으리라는 가정이 있었다. 그러나 불행하게도, 새로 추가하고 싶은 흥미로운 기능은 대개 기존 코드 및 기존 데이터 레이아웃 방식과 호환되지 않으며, 새 기능을 허용하도록 기존 코드를 다시 작성하거나, 새 데이터 레이아웃을 허용하도록 기존 코드를 다시 작성해야 한다. 만약 소프트웨어 프로젝트를 기존 솔루션들, 즉 이전 프로젝트에서 기능을 제공하기 위해 만들어 둔 객체들을 조합해 만들 수 있다면, 그 프로젝트는 그리 복잡하지 않을 것이다.
상당한 복잡성을 가진 프로젝트라면, 그 프로젝트만의 모든 특수 요구사항을 충족하는 수백, 수천 개의 특수 객체를 포함하게 마련이다. 예를 들어, 대다수 게임에는 player 클래스가 존재하지만, 공통된 코어 속성을 공유하는 경우는 거의 없다. 포커 게임의 플레이어에게 월드 좌표 속성이 필요할까? 레이싱 게임의 플레이어에게 HP(hit point) 카운트 속성이 필요할까? 완전히 오프라인으로만 동작하는 게임에서 플레이어가 게이머 태그를 가져야 할까? 재사용 가능한 범용 클래스를 하나 만들어 둔다고 해서 게임 제작이 쉬워지는 것은 아니다. 단지 특수화(specialisation)의 위치를 다른 데로 옮길 뿐이다.
일부 게임 툴킷은 스크립트를 통해 기본 클래스를 확장할 수 있도록 함으로써 이를 처리한다. 어떤 게임 엔진은 게임플레이를 특정 장르로 제한하고, 데이터 중심 방식으로 그 장르에서 어느 정도 벗어날 수 있게 한다. 아직까지 “게임 API”를 만든 사람은 없다. 그렇게 하려면 API가 너무 범용적이어야 해서, 결국 우리가 이미 개발 언어에서 가지고 있는 것 이상을 제공하지 못할 것이기 때문이다.
재사용성은, 제작(프로덕션) 측에서 갈망하고, 게임 개발 경험이 별로 없는 사람들에게 높이 평가되며, 이 때문에 많은 게임 개발자들에게 그 자체가 목표가 되었다. 범용(generic) 코드의 함정은, 왜, 어떻게 범용성을 유지해야 하는지에 대한 고민 없이, 클래스를 재사용 또는 재목적화(re-purpose)할 수 있도록 “충분히 범용적”으로 유지하려는 집착에 있다.
먼저 “왜”부터가 큰 걸림돌이며, 가능한 한 빨리 개발자들에게서 교정되어야 한다. 단지 일반성을 위해 무언가를 범용적으로 만드는 것은 유효한 목표가 아니다. 처음부터 범용적으로 만드는 것은, 가치를 더하지 않는 개발 시간 증가만 초래한다. 일부 개발자는 이를 “근시안적”이라고 할 수 있다. 그러나 “어떻게”를 살펴보면 이 주장은 힘을 잃는다. 단 하나의 사용 사례만 있는 클래스를 어떻게 일반화하겠는가? 클래스 구현은 사용된 범위 내에서만 테스트할 수 있다. 단 하나의 장소에서만 사용하는 클래스를, 오직 그 한 상황에서만 테스트할 수 있을 뿐이다. 클래스의 재사용성 품질은, 실제 재사용 사례가 나오기 전까지는 본질적으로 테스트 불가능하다. 일반적인 경험칙에 따르면, 세 개 이상의 서로 다른 사용처가 생기기 전까지는, 그 클래스가 재사용 가능하다고 말할 수 없다.
이제 클래스를 범용적으로 만들었는데, 여전히 첫 번째 상황밖에 테스트 케이스가 없다면, 당신이 테스트할 수 있는 것은, 그 클래스를 일반화하면서 기존 동작을 깨지 않았다는 사실뿐이다. 그러므로, 다른 타입이나 상황에서도 이 클래스가 동작할 것이라 보장할 수 없다면, 클래스를 일반화함으로써 한 일은, 버그가 숨어들 수 있는 코드만 늘린 것이다. 그 결과 생긴 버그는, 이미 작동하는 코드(심지어 고립된 상태에서 테스트되었을 수도 있는)의 안에 숨게 된다. 즉, 일반화하는 과정에서 새로 도입된 버그들은, 도장을 찍고 승인된 코드 속으로 들어가, 이제 더 이상 의심받지 않게 된다.
테스트 주도 개발(TDD)은, 좋은 시기가 오기 전까지는 범용 코딩을 사실상 부정한다. 코드를 더 범용적인 상태로 옮기는 것이 좋은 선택이 되는 시점은, 중복을 줄이고 공통 기능을 재사용하게 될 때뿐이다.
일반 코드는, 많은 상황에서 사용되려면, 기본적인 기능 이상을 수행할 수 있어야 한다. 예를 들어 템플릿 기반 배열 컨테이너를 작성한다고 하자. 대괄호 연산자를 통한 배열 접근은 기본 기능으로 여겨질 것이다. 그러나 이 외에도 이터레이터를 구현하고, 배열을 메모리에서 하나씩 밀어 올리는 일의 부담을 덜어 주기 위해 insert 같은 루틴을 추가하고 싶어질 것이다. 이 기능들을 필요할 때마다 다시 구현하면, 작은 버그들이 스며들 수 있다. 링크드 리스트는, 날림 구현에서 버그가 생기기 쉬운 자료 구조로 악명이 높다.
모든 일반 컨테이너는, 모든 사용자를 지원하기 위해, 조작을 위한 완전한 메서드 집합을 제공해야 한다. STL은 이를 제공한다. 수백 개의 서로 다른 함수들을 이해해야, STL 전문가라고 할 수 있고, STL 전문가가 되어서야 비로소 STL을 사용해 효율적인 코드를 작성하고 있음을 확신할 수 있다. STL 구현들에 대한 문서는 매우 방대하다. 구현들 간에는 대부분 매우 비슷하거나, 기능적으로 동일하다. 그럼에도 불구하고, 프로그래머가 쓸 만한 STL 프로그래머가 되기까지는, 이 새로운 언어를 배워야 하는 탓에 어느 정도 시간이 걸린다. 프로그래머는 STL이라는 새로운 언어, 그 명사, 동사, 형용사를 익혀야 한다.
이를 줄이기 위해, 많은 게임 회사들은 메모리 핸들링(까다로운 하드웨어 환경을 고려한) 개선 옵션, 컨테이너 선택의 폭(예: 단순히 map뿐 아니라, hash-map, trie, b-tree를 직접 선택할 수 있게 하는 것), 또는 stack이나 단일 연결 리스트(singly linked list), 침투형(intrusive) 리스트 같은 단순 컨테이너의 명시적 구현 등을 제공하는, STL을 축소 재해석한 라이브러리를 사용한다. 이런 라이브러리들은 보통 범위가 더 좁아서, STL 변종들보다 배우기 쉽고 수정하기도 쉽지만, 여전히 배워야 하고, 이에는 시간이 든다. 과거에는 이것이 괜찮은 타협이었다. 하지만 이제 STL에는 방대한 온라인 문서가 있으므로, 메인 메모리가 킬로바이트 단위로 측정되는 임베디드 공간처럼, 메모리 오버헤드가 매우 침습적이거나, 컴파일 시간이 엄청난 관심사인 경우11.4를 제외하면, STL을 사용하지 않을 이유가 없다.
여기서 얻을 수 있는 요지는, 일반 코드라도, 코더가 효율적으로 작업하거나, 우발적인 성능 병목을 만들지 않으려면, 반드시 배워야 한다는 점이다. STL을 사용하기로 했다면, 적어도 풍부한 문서가 있다는 장점이 있다. 그러나 당신의 게임 회사가 경이롭게 복잡한 템플릿 라이브러리를 구현했다고 해도, 코더들이 그것을 익힐 시간을 갖기 전까지는 아무도 사용하지 않을 것이라 기대해야 한다. 범용 코드를 작성하면, 사람들은 그것을 우연히 발견하거나, 명시적으로 사용하라는 지시를 받지 않는 이상 사용하지 않을 것이다. 존재를 모르거나, 믿지 않기 때문이다.
다시 말해, 처음부터 범용 코드를 작성하는 것은, 개발에 아무런 가치를 더하지 않은 채, 많은 코드를 빠르게 쓰는 훌륭한 방법이다.
온라인 공개판 Data-Oriented Design :
이 문서는 무료 온라인 축약판입니다. 일부 중요하지 않은 장들은 제외되었지만, 데이터 지향 설계를 배우고자 하는 이들에게 필요한 핵심 내용은 모두 포함되어 있습니다.
이 문서는 자동 생성되었고, LaTeX를 HTML로 변환하는 도구들이 완벽하지 않으므로, 다소 깨진 포매팅이나 이미지, 코드 리스트가 있을 수 있습니다. 소스 코드 리스트가 깨져 있다면, 인용된 소스는 GitHub에서 찾을 수 있습니다. 이 글이 마음에 든다면, 여기에서 종이책을 구매하는 것을 고려해 주세요. 종이책은 훨씬 보기 좋을 뿐 아니라, 이 온라인 버전을, 책을 살 형편이 되지 않는 이들을 위해 계속 유지하는 데에도 도움이 됩니다. 피드백은 언제든지 support@dataorienteddesign.com으로 보내 주세요.
Richard Fabian 2018-10-08