18. 더 최적화할 방법은 없을까?
• 컴포넌트가 크다면 캐스 미스가 발생할 확률이
높다
• 빈번하게 사용되는 데이터와 한산한 데이터로
나누자!!!
(코드 예제)
https://github.com/sukwoo22/DataOrientedDesign
19. 결론
• 기존의 OOP는 현대 하드웨어에 친화적이지 않다.
• 하지만 OOP 장점은 버리기 아깝다!
• 따라서 OOP와 DoD를 함께 이용하자!!
컴포넌트 별로 배열로 묶는다.
더티 플래그 사용을 자제한다
바쁜 코드와 한산한 코드를 분리한다.
20. 참조
• 게임 프로그래밍 패턴 – 컴포넌트, 데이터 지역성
저자 로버트 나이스트롬, 번역 박일
• Pitfalls of Object Oriented Programming
발표 Tony Albech – Sony Technical Consultant
• Component Based Engine Design
Randy Gaul – 블로거
• Entity-systems.wikidot.com
Entity Systems Wiki
Component/Entity 시스템은 게임 개발 분야에서 주로 사용되는 아키텍쳐 패턴이다. CES는 상속의 원리를 이용하여 엔티티를 정의하고 해당 개체의 특성에 맞게 컴포넌트들을 조합할 수 있는 유연하고 효율적인 시스템을 말합니다. 여기서 Entity와 Component는 무엇일까요?
컴포넌트는 엔티티에 필요한 기능들을 독립적으로 정의한 객체입니다. 예를 들어, 게임 속에서 활동하는 몬스터에겐 인공지능과 물리적인 특성 그리고 화면에 보여주기 위한 렌더링이 필요하지요. 엔티티는 컴포넌트를 담는 하나의 컨테이너입니다. 이 컴포넌트의 조합을 통해서 프로그램 내에서 특수한 역할을 가진 객체로서 존재합니다.
감이 오시나요? 예를 한번 들어보겠습니다. 게임 속 캐릭터, 특정 조건을 만족시켜야 발생되는 이벤트, 던전 곳곳에 도사리는 트랩, 록맨의 E캔과 같은 아이템 등 모두 엔티티가 될 수 있습니다.
실제 게임에서는 다양하고 세부적인 기능을 가진 엔티티가 필요로 하기 때문에, 엔티티는 계층구조를 이루는 것이 특징입니다. 객체 지향 프로그래밍에서는 이를 상속을 통해서 구현합니다. 덕분에 코드의 재활용이 상당 부분 가능해지고 그 결과 소프트웨어의 생산성이 훨씬 증가하게 되죠. 자 여기서 이해를 돕기 위해 오크를 더 세부적으로 살펴보겠습니다.
예를 들어 오크 특성을 상속 받은 개체들은 기본적으로 힘이 세고 에너지가 많습니다. 이것을 각 객체 하나 하나를 일일이 정의하는 것 보다 공통된 속성을 Orc 엔티티에 묶어 놓고 이것을 상속받아 필요한 컴포넌트만 추가하는 것이 효율적이기 때문입니다.
이제 코드를 보면서 설명해 보도록할까요? 먼저 컴포넌트 클래스가 있습니다. 여기에는 반복적으로 갱신되는 업데이트 함수가 있습니다. 그리고 상속을 통하여 AI와 Physics 그리고 렌더링 컴포넌트를 정의하였습니다. 렌더링 컴포넌트는 추가적으로 렌더 함수가 있겠죠? 그리고 다음은 이 컴포넌트들을 담는 엔티티 클래스 입니다. 엔티티 클래스에서는 그 컴포넌트을 담은 배열이 있고 이곳에 삽입하고 제거하는 함수가 있습니다. 그리고 엔티티들을 관리하는 엔티티 매니져 클래스들이 있습니다. 이 매니져에 대한 배열을 메인 엔진이 가지고 있습니다. 여기서는 위의 클래스들을 순차적으로 순회하여 각 컴포넌트들에 대한 업데이트와 렌더 함수를 메인 루프에서 호출되도록 합니다. 자, 이 코드의 문제점은 뭘까요?
그림을 보면서 설명하겠습니다. 앞에서와 같이 전통적인 OOP 방식으로 코딩을 하면 힙이라 불리는 메모리 공간에서 그림과 같이 랜덤하게 엔티티와 컴포넌트들이 생성됩니다. 왜냐고요? 우리가 사용하는 new 키워드와 생성자를 이용한 객체 생성 방식은 객체들의 위치까지 정렬시켜주지 않기 때문이죠. 이것은 캐시 미스가 발생할 확률을 높이는 요인으로 프로그램 성능에 안좋은 영향을 끼칩니다.
자 정리해 볼까요?
대체 왜 이렇게 느릴까요? 그 이유는 메모리와 CPU의 성능차이에 있습니다. 1980년에는 CPU와 메모리의 성능 차이가 거의 없었습니다. 하지만 지금은 갈수록 그 차이가 현격하게 벌어지고 있죠. 이 때문에 CPU가 아무리 빠르더라도 메모리로부터 데이터를 읽어 오길 기다리느라 CPU의 실행이 멈추게 되는 병목 현상이 생기게 되죠.
메인 RAM에서 데이터를 가져다 쓰는 비용을 줄이기 위해서 오늘날의 프로세서는 캐시를 이용합니다. 캐시란 CPU 바로 옆에 붙어 있는 고성능 메모리입니다. 즉, 메인램에서 데이터를 직접 받아오면 느리지만 캐시를 이용하면 훨씬 빨라지지요. 하지만 캐시에서 데이터를 읽어오기 위해선 먼저 메인램에서 캐쉬로 메모리 조각을 이동시키는 과정이 필요합니다. 이 메모리 조각을 캐시 라인이라고 합니다. 캐시 라인은 현재 필요한 데이터 하나만이 아니라 그 데이터 주변 메모리까지 포함되죠.
캐시에 데이터가 없어서 메인 램에서 데이터를 읽어오는 경우를 바로 캐시 미스라 하는 데요. 자 다시 뒤로 돌아가서 방금 전의 코드는 왜 캐시 미스를 발생시키는 걸까요? 엔티티 차례로 읽어 온다고 과정해 봅시다. 자 먼저 1번 엔티티는 처음이니까 당연히 캐시에 없겠죠? 캐시 미스가 발생합니다. 대신 엔티티 주변 메모리 조각을 캐시로 이동시킵니다. 2번 엔티티는 캐시에 있을까요? 없죠. 또 캐시 미스가 발생했네요. 다시 캐시 라인을 캐시로 이동시킵니다. 3, 4번도 마찬가지로 캐시 미스가 발생합니다. 이제 이해 되시나요? 이렇기 때문에 전통적인 OOP 방식으로 코딩하면 문제가 되는 겁니다.