이번 글에서는 C++ 개발자로써 Data-Oriented Design을 세상에 소개한 Mike Acton이라는 사람의 유명한 CppCon 발표를 다뤄보고자 한다. 

인섬니악 게임즈의 최근 대표작인 Marvel’s Spider-man 2

발표자 Mike Acton은 2014년 당시에 Insomniac Games의 엔진 디렉터였으며, 플레이스테이션을 비롯한 여러 콘솔 디바이스에서 게임을 구현하기 위해 다양한 노력들을 해왔다. 이후 Unity3d Engine의 기술 디렉터를 역임하였으며, 이 시기에 Unity Engine에 ECS(Entity Component System)이라는 Data-Oriented Design GameObject 시스템 개발을 주도한 것으로 알려져 있다.

2014년도에 그가 CppCon에서 발표한 “Data-Oriented Design and C++”는 지금까지도 많은 사람들에게 회자되고 있는 명강연이다. 개인적으로도 약 1시간 30분 가량의 발표동안 단 한순간도 눈을 뗄 수가 없었다. 너도나도 C++는 로우레벨 언어라고들은 쉽게 말하지만, 정말 C++로 로우레벨을 다룬다는 것이 무엇인지 이 발표에서는 제대로 보여주며, 이를 통해 "Data-Oriented Design"의 덕목이 어떻게 실현될 수 있는지를 역설한다. 

1. Data-Oriented라는 것이 실제로 무엇인가?

그는 가장 먼저 Data-Oriented Design principle들을 다음과 같이 소개했다.

The purpose of all programs, and all parts of those programs, is to transform data from one form to another.

Mike는 먼저 프로그램이라는 개념에 대해서 이렇게 정의하였다. 요컨대 이런 것이다.

  • 계산기란 수식 데이터를 정답 데이터로 변환하는 프로그램이다.
  • 게임이란 게임 속 세상을 구성하는 데이터와 사용자의 입력 데이터를 화면 데이터으로 1초에 60번씩 변환하는 프로그램이다.
  • 운영체제란 메모리에 있는 데이터와 사용자의 입력데이터를, 메모리에 저장될 또 다른 데이터와 화면 출력으로 컴퓨터가 꺼질 때까지 변환하는 프로그램이다.

If you don't understand the data you don't understand the problem.

데이터를 이해하지 못하면 문제를 이해하지 못한 것이라니 실로 데이터 중심의 사고가 아닐 수 없다.
반대로 말해 문제를 제대로 이해하려면 데이터를 먼저 잘 이해해야 하며 데이터가 다르다면 문제 역시 다른 것이고, 이에따라 문제가 서로 다르다면 솔루션 역시 서로 다르다고 설명했다.
이때 어떤 문제에 대한 솔루션 비용(Cost of solving problem)을 이해하지 못했다면, 그것은 문제를 이해한 것이 아니며, 하드웨어를 이해하지 못한다면 문제의 솔루션 비용도 판단할 수 없다고 말했다.
요약하자면, Data-Oriented design으로 문제를 푼다는 것은 데이터와, 해결하려는 문제와, 해법의 비용과, 해법을 수행하는 환경 모든 것에 대한 이해가 수반되어야 한다. 이로써 사용성, 유지보수성, 디버깅 용이성들도 결국엔 데이터와 연관된 문제라는 것이다.

Rule of Thumbs

1. 데이터란 하나가 있다면, 여러 개도 있다. 항상 시간 축 위에서 고려하기 위해 노력하라. Context를 더 많이 알 수록 더 나은 솔루션을 얻을 수 있다. 필요한 데이터를 무시하지 마라.
2. 즉각적인 I/O나 프로그램의 Pre-built data에서부터 하드디스크같은 원본 데이터에 접근하는데에는 모두 각기다른 시간이 소모됨을 유의해라.
3. Reason must prevail. 소프트웨어란 컴퓨터과학 연구실 어느 열정어린 박사의 마법같은 환상 위에서 실행되는 것이 아니라, 철저히 현실 속에서 현실의 데이터를 다루는 것이다.


2. 흔히 알려진 세가지 거짓말

Mike는 우리가 경계하고 경계해야할 프로그래머 업계에 널리 퍼진 3가지 거짓말을 다음과 같이 정의했다.

거짓말 1. 소프트웨어는 플랫폼이다.

하드웨어야말로 플랫폼이다. 같은 문제에 대해 구글의 거대한 서버팜과 손바닥만한 조그마한 아두이노가 같은 솔루션을 취할 수는 없다. 하드웨어마다 서로 다른 물리적인 제약과 유한한 자원이 있는데 어떠한 소프트웨어 솔루션도 이로부터 독립적일 수는 없다. 현실이란 당신의 이론적이고 추상적인 문제에서 맞딱드릴 장애물 같은 것이 아니다. 현실이 문제 그 자체다.

거짓말 2. 코드는 실제 세계에 대한 모델링 기반으로 설계되어야 한다

실제 세계를 모델링 한다는 것은 대개 다루려는 실제 데이터를 뒤로 숨기는 것이다. 그런데 이는 1) 프로그램이 좋은 유지보수성을 갖게 만드는 것과 2) 문제를 풀기위해 데이터의 속성을 잘 이해하는 것의 차이를 쉽게 혼동하게 만든다. 개념적으로 깔끔하게 추상화하여 모델링을 잘하면 1)을 성공적으로 달성할 수 있겠지만, 그것이 반대로 2)를 굉장히 어렵게 만들 수도 있다는 것이다.

예를 들어 chair에 대해 모델링 한다면, 가장 기본이 되는 chair로부터 여러가지가 파생될 것이다. 하지만 의자라는 개념 그 자체는 문제도 아니고 데이터도 아니다. 다뤄야하는 실제 문제는 그보다는 ‘부서지는 의자’, ‘앉을 수 있는 의자’, ‘장식용 의자’ 등등을 구분하여 이를 각각 알맞게 처리하는 것이며, 이 때 chair라는 공통 속성이 있다는 것은 그다지 유용한 정보가 아니다.

모델링은 대개 문제를 보다 단순한 개념으로 이상화하고자 시도하지만, 문제를 문제 그 자체보다 더 단순하게 만들 수는 없다. 모델링은 좋은 비유, 좋은 스토리 텔링이 될 수는 있지만 문제 해결에서도 무조건 좋지만은 않다.

거짓말 3. 코드는 데이터보다 중요하다.

코드는 단지 어떤 데이터를 다른 어떤 데이터로 바꾸기 위함이 목적이다. 여기서 중요한 것이 과연 코드일까 데이터일까? 프로그래머가 책임감을 가져야 하는 것은 코드일까 코드가 만들어내는 데이터일까?

그래서 이를 잘 구분하기 위해서는 가장 먼저 다뤄야할 데이터가 무엇인가에 대해 정확히 아는 것이 중요하다고 말한다. 따라서 코드는 주어진 제약조건 속에서 데이터를 잘 가공하기 위한 도구 그 이상 그 이하도 아니라고 강하게 주장한다.

이어서 Mike는 이 세 가지 거짓말들이 가져온 악영향을 이렇게 소개했다.

  • 나쁜 코드 퍼포먼스
  • 나쁜 동시성
  • 나쁜 최적화 가능성
  • 나쁜 안정성
  • 나쁜 테스트성

여기까지 들었을 때 들었던 의문은, 저 부작용들이 과연 저 세가지 거짓말 때문에 일어나는 것들일까? 였다. 곰곰히 생각해보면, 많은 개발자들이 사랑해 마지않는 '클린XX' 개념들 중에 좋은 코드 퍼포먼스, 좋은 동시성, 좋은 최적화 가능성을 이야기 하는 것은 접해본 적이 별로 없는 것 같다. 그보단 유연하고 깔끔하고 유지보수성 높은 코드를 작성하는 것에 좀 더 치중했지 않았나? 하는 생각이다.
 

3. Dictionary LookUp을 구현해보세요.

Mike는 곧바로 간단한 예시를 보여주었다. Dictionary LookUp기능을 구현하기 위한 코드를 작성할 때, 으레 개발자들은 위와 같은 그림을 머릿속에 그릴 것이다. Key-Value Pair로 구성되어 있으니 실제로 구현할 때도 메모리에 이런 모양으로 데이터 레이아웃을 구성했을 것이다. 떠오르는대로 작성해보자면 아래와 같을 것이다.

template<typename KEY_TYPE, typename VALUE_TYPE>
struct Dictionary
{
VALUE_TYPE operator[](const KEY_TYPE& key);
std::vector<std::pair<KEY_TYPE, VALUE_TYPE>> data;
};

 
하지만 곧바로 Mike는 이런 아이디어가 바로 위에서 말한 "Code-first Design"이며, 이러한 설계가 놓친 맹점을 바로 이렇게 짚어준다.
 
"Key와 Value 모두 동등한 확률로 필요할까? 아니다. 우리는 대부분의 시간을 Key 목록을 조회하는데 사용할 것이다. Value는 우리가 정말로 얻고자 하는 Key값에 대해서만 한 번 필요하다. 엄밀히 말해, Key와 Value는 개념적으로는 쌍을 이루지만 실질적으로는 연관성이 없다."

사실 생각해보면, 우리가 Dictionary에서 무언가를 조회하는 동안에는 다른 Key에 해당하는 Value들은 전혀 궁금하지 않다. 도서관에서 원하는 책을 찾기 위해 이동하는 길에 보이는 모든 책을 다 꺼내볼 필요가 없는 것처럼, Key목록을 조회하는 동안 캐시라인 위에 Value들까지 올릴 필요가 전혀 없다는 뜻이다.

"Code-first Design"이 아니라 "Data-first Design"을 한다면, 실제로 Dictionary의 메모리는 이렇게 생겨야 한다는 뜻이다. 이렇게 하면 CPU 캐시 상에 Value들은 올라올 일이 없고, 적절한 Index가 정해진 시점에만 한 번 로드되기 때문에 훨씬 효율적인 코드가 된다는 주장이다.

template<typename KEY_TYPE, typename VALUE_TYPE>
struct Dictionary
{
VALUE_TYPE operator[](const KEY_TYPE& key)
{
	return values(keyToIndex(key));
}

int keyToIndex(const KEY_TYPE& key);
std::vector<KEY_TYPE> keys;
std::vector<VALUE_TYPE> values;
};

 

4. "플랫폼에 대한 이해가 중요하다."

여기까지는 그럭저럭 따라올만 하다고 생각했는데, Mike가 갑자기 CPU CYCLE표를 보여주니 다소 당황스럽다. 그러나 그는 정신 차릴 시간을 주지 않고 폭주기관차처럼 설명을 이어간다.
위의 표는 AMD cpu에서 sin, cos, tan 같은 삼각함수연산을 위한 명령어가 실행되는데 걸리는 시간을 나타낸다. 여기서 말하는 Latency란 CPU CYCLE을 의미한다. 즉 위의 표에 의하면 AMD에서 Float point Sin 함수를 연산하는데 일반적으로 60~146 CYCLE를 소모한다고 한다. 밑도끝도 없이 갑자기 이 표를 왜 보여주지? 라고 생각했을 때 곧바로 본론으로 넘어간다.

Playstation4에서의 메모리 캐시 접근 Cycle

위의 표에 의하면, L1 캐시에 있는 메모리에 접근하는 것은 고작 3 CYCLE밖에 들지 않지만, 기존의 캐시라인으로부터 물리적으로 멀리 떨어진 주소의 메모리에 접근을 시도하면 와장창 캐시 미스가 발생하여 Main Ram까지 다녀오는데 200+ CYCLE이 소요된다. 위의 삼각함수 연산에 드는 비용이 140 사이클 정도 이내인 것을 고려하면 상당히 무거운 비용이라고 할 수 있다.
위의 Dictionary 예제에서 만약 VALUE_TYPE이 큰 사이즈의 타입이라면, Data-Oriented Design으로 설계하지 않은 코드에서는 Key를 조회하는 매 Iteration마다 캐시 미스가 발생, 즉 200+CYCLE의 연산량을 소모할 것이다.
이 슬라이드를 보며 지난날의 과오를 되돌아보니 내가 CPU에게 저지른 죄가 너무 많아 정신이 아득해진다.

벌써부터 중간에 있는 m_Name[32] 변수가 눈에 거슬린다.

정신을 추스를 겨를도 없이, 이번엔 좀 더 강한 팩트 폭행이 가해진다. 위의 코드는 일반적으로 게임엔진에서 많이 사용되는 코드이다. 코드를 보아하니 2차원 공간에서 위치와 속도를 지닌 객체에 대해 모델링한 것으로 보인다.  그런 다음 UpdateFoo함수를 보면 m_Foo라는 어떤 magnitude 값을 계산하는 함수가 있는 것을 볼 수 있다. 이런 디자인은 거의 대부분의 GameEngine에서 볼 수 있는 디자인이다. 이 클래스만 보면, GameObject가 어떤 프로퍼티를 가지고 있고 어떤 기능들을 가지고 있는지 한 눈에 알아볼 수 있다. 아주 잘 짜여진 코드라고 . 할 수 있겠다. "Code-First Design"에서는 말이지.
 
자 이제 이런 가정을 해볼 수 있다. 이 GameObject 사이즈는 최소 60 byte쯤 될 것이다. m_Name과 m_Foo 사이에 훨씬 더 많은 멤버가 선언되어 있다면 그보다 훨씬 커질 수도 있다. 하지만 여전히 m_Foo는 객체의 시작점에서 가장 멀리 떨어진 주소에 있을 것이다. 그러면 이제 아래의 UpdateFoo함수를 기계어로 번역한 결과를 보자.

최초에 값 두개를 로드할 때, 운좋게 L2 캐시라인에서 값을 로드했다면 ~200 cycle이 소모된다.
2개의 float값을 로드하는 데에 ~10 cycle, sqrt 연산을 하는데 ~40 cycle
그런 다음 m_Foo에 연산 결과를 저장하려고 하는데 m_Foo가 저 멀리 떨어져 있는 바람에 L2 캐시 미스가 발생하여 추가로 ~200 cycle이 더 돌았다.

 
즉 UpdateFoo 함수에서 실제 중요한 로직을 수행하는데는 40cycle이지만 필요한 데이터를 로드하는 데에는 400 cycle이 소모되었다.
이렇게 숫자로 놓고보면 정말 어마어마한 낭비가 아닐 수 없다.
그런데 게임에는 GameObject가 십수개 있는 것이 아니라 천개 만개씩 존재하기도 한다. 엄청난 규모의 Scene에서는 한 번에 10만개 가량의 GameObject가 존재할 수도 있다. 그런 상황에서도 만약 GameObject 구조체가 위와 같이 설계되어 있다면, 이런 종류의 연산을 할 때마다 실제로 전체 문제에서 CPU 자원은 10%밖에 활용을 못한다는 뜻이다.

이 또한 컴파일러님께서 다 알아서 해주지 않을까 희망을 품는 불경한 자들을 위해 Mike는 이렇게 덧붙인다. 컴파일러 기술이 아무리 나날이 발전한다 하더라도, 코드 상에 선언된 클래스 구조까지 최적화 해주지는 못한다. 아무리 컴파일러가 열심히 최적화 해준다고 하더라도, 여기서는 전체 문제의 10% 내외에서만 그 능력이 발휘된다는 뜻이다.
 

5. Code-first가 이렇게나 위험합니다.

딱 봐도 L2캐시가 비명을 지를 것 같이 생겼다.

슬라이드의 후반에서는, Mike는 주저없이 유명한 오픈소스 게임엔진인 Ogre의 클래스 구조를 적나라하게 고발한다. Node는 일종의 GameObject 클래스인데, 여기에는 총 5개의 문제가 있다고 지적한다.
1) Cant' re-arrange memory

이렇게 정의된 멤버 변수들은 메모리 액세스 패턴을 예측하기가 매우 어렵기 때문에 컴파일러에서 re-arrange를 시도조차 할 수 없다. 무엇이 자주 읽혀지고 무엇이 자주 쓰여지는지, 이렇게 객체의 모든 멤버 변수가 한 번에 선언되어 있는 상황에서 컴파일러가 할 수 있는 것은 제한 적이다.
 
2) Bools and last-minute decision making


Node라는 객체는 GameObject이기 때문에 실제로 상당히 많은 숫자의 Instance가 한 번에 존재할 것이다. 그리고 개별 GameObject마다 동작유형, 관리 방식 등등도 모두 다를 것이다. 표시된 변수 이름들이 아마 이런 것들을 제어하기 위한 Boolean Flag 값일 것이다. 문제는 이 정보가 Node 인스턴스 안에 있다는 것이다. 따라서 어떤 Node가 어떻게 동작할 것인지는 실제로 그 Node의 메모리에 직접 접근해서 까봐야지만 즉, Last-minute에서야 알 수 있다는 뜻이다.
위에서 예시로 Dictionary LookUp을 예로 들자면 Value가 자신과 매칭된 Key값을 들고 있어서, Dictionary 조회를 할 때 모든 Value를 다 까보면서 Key가 매칭되는지 확인하는 것과 마찬가지라는 뜻이다.
 
3) Over-generalization

지나치게 일반화 되어서 객체 하나가 너무 많은 역할을 담당하고 있는 것이 문제라고 주장한다.
1. 많은 Property들로 인해 Property들에 대한 read/write를 효율적으로 관리하기가 어려워질 것이다.
2. 생성자와 소멸자에서 불필요한 read/write가 일어날 것이다.
3. 가상함수가 많아지기 때문에 vtable 참조가 많아지면서 I-cache 역시 효율적으로 관리되지 못할 것이다.
4. 객체가 가질 수 있는 State가 기하급수적으로 복잡해진다. 여기 존재하는 Boolean Flag만으로도 2^7 개 이상의 State를 가질 수 있다.
 
4) Undefined or under-defined constraints

생성자에서 mName 멤버는 msNameGenerator.generate()라는 함수에서 생성한 것을 그대로 받아서 할당한다. 변수명에서 유추할 수 있듯이 String은 길이 자체도 가변적인데, generator라는 함수가 넘겨주는 값이 어떤 것일지 이 코드에서는 도저히 예측할 수가 없다. 매우 우 긴 이름을 가지고 있다면 이 Node는 다른 Node들에 비해 훨씬 큰 메모리를 차지할 가능성이 존재한다. Mike는 이런 이유 하나만으로도 객체 Instance에 String 멤버 변수를 사용하는 것은 '일반적으로 나쁘다' 라고 덧붙인다.
 
5) Over-solving

마지막 한 줄까지도 남김없이 탈탈 털린다. 대체로 needUpdate 함수는 이 인스턴스가 가진 멤버들이 최신화되어야 한다고 flag를 켜주거나 최신화를 수행하는 함수이다. 그런데 초기화 시점에 반드시 최신화가 필요할까? 생성자에서 초기화된 값 그 자체로도 최신상태일 수도 있지 않을까? 아무튼 알잘딱으로 잘 해줬으면 하는 개발자의 얕은 뜻을 컴파일러는 깊이 알지 못한다. 
 

6. 우리는 어떻게 해야 하나요?

위의 문제에 대한 정답은 1:01:29 부터

훌륭한 오픈소스 게임엔진인 Ogre를 뼛속까지 탈탈 터는 장면을 눈앞에서 목도했을 때 참석자들의 표정까지 화면에 담기지는 않았겠지만, 아마 모두가 망연자실한 표정이었을 것이다. Mike는 이들을 위해 위에서 지적한 문제들에 Data-Oriented Design 관점에서 어떻게 재설계할 수 있는지도 친절하게 정답을 알려준다. 그것까지 이 글에 담기에는 내용이 매우 많기 때문에귀찮고 영상을 통해 직접 확인하기를 바란다.

끝으로, Mike는 앞서 소개 했던 세 가지 거짓말들에 대해서 다음과 같이 진실을 바로잡으며 마무리한다.
* 하드웨어가 여러분의 플랫폼입니다.
* 이상적인 세계를 모델링 하지 마세요. 실제로 다루려는 데이터 자체에 집중하세요.
* 당신은(프로그래머로써) 데이터를 가공하는 것에 책임이 있지, 좋은 코드를 작성하는 것에 있지 않습니다.
 

7. 마치며

Data-Oriented Design의 효용성과 실용성에 대해서는 여전히 의견이 분분할 것이다. 그러나 이 발표자가 자신이 주장하는 바와 개념에 대해서 화두를 던지고 결론까지 도달하는 발표자의 논리 전개 과정은 그 누구도 토달 수 없도록 현실에 기반하여 실증적으로 진행되었다. 나도 꽤나 성능에 민감한 개발자에 속하고, 하드웨어 성능을 극한까지 끌어올리는 것을 좋아하지만 이 사람은 그 이상이었다. 발표를 끝까지 봤을 때 CPU로 하는 한 편의 차력쇼를 보는 느낌이라고 할까.

Q. 사실 프로그램이 목적을 달성했냐가 중요하지, 그것이 얼마나 오래걸리는지는 덜 중요하지 않을까요? A. 당신이었군요 Word가 켜지는데 30초씩 걸리게 만든 사람이.


끝으로 20분에 걸친 질의응답 세션도 정말 예술이다. “이건 data-oriented가 아니라 Cache-oriented design 아닌가요?”라거나 “당신 C++개발자가 맞기는 한가? 대부분 C아니면 어셈블리어들인데?” 같은 공격적인 질문들도 더러 있었다. 하지만 그는 시종일관 단단하고 확신에 찬 어조로 답변하곤 했다.

그의 말 중에 가장 인상 깊었던 것은, "알아요. 하지만 저는 어딘가에서 이런 것들을 신경쓰는 엔지니어들도 있다는 사실을 알리고 싶었습니다." 라는 말이었다. 이토록 자신의 업에 대한 확고한 철학과 신념은 어디에서 나오는 것일까? 새삼 존경스러운 마음이 일었다.

'개발 > C++' 카테고리의 다른 글

첫 걸음부터 시작하는 Data Oriented Design과 C++  (1) 2024.09.30

1. 서론

오늘날 소프트웨어 개발에서 성능은 중요한 요소로 자리 잡고 있다. 어느 시점부터 프로그래밍 언어의 덕목이라 함은 쉬운 사용성에 더 기울어지고 있는 요즘이지만, 그런 것들로 인해 게임 개발, 그래픽 프로그래밍, 실시간 데이터 처리 애플리케이션 등에서 요구하는 고성능 지향의 최적화가 덩달아 용이해지지는 않는다. 다시 말해 아무리 더 넓은 저변의 사용자를 지향하고, 더 편한 사용성, 더 쉬운 개발을 지향하는 언어가 나온다 하여도, 그것으로 개발된 프로그램이 실행되는 환경이 변하지는 않았다. 우리는 여전히 가산기-누산기-레지스터의 구조로부터 만들어진 CPU 위에서 실행되는 프로그램을 만들고 있다.

그렇다고 해서, 어떤 문제를 좀 더 풀기 쉬운 형태의 개념으로 해석하고 풀이하고 고찰하는 것의 가치와 의미가 퇴색되지는 않는다. 그로써 고안된 수많은 프로그래밍 패러다임과 디자인 패턴은 지금도 계속해서 어둠 속을 헤매는 개발자들에게 길잡이 별이 되어주고 있다. 전통적인 객체 지향 설계(Object-Oriented Design, OOD)는 그중에서도 아주 오랜 기간 동안, 그리고 지금까지도 우리가 프로그래밍으로 풀어내고자 하는 문제에 대한 편리한 모델링 도구로써 많은 개발자들에게 사랑받고 있는 개념이다. 그러면서도 동시에 최근에는 데이터 중심 설계(Data-Oriented Design, DOD)가 주목받고 있는 것은 왜일까를 이 시리즈를 통해 고민해보고자 한다.

그 첫 번째로써, 이번 글에서는 우선 OOP와 DOD의 개념을 비교하고, 각각의 장단점과 실제 예제를 통해 기존 프로그래밍 패러다임들에 대해 DOD가 도전하는 영역들을 탐구해보고자 한다.

 

2. 객체 지향 설계(OOP)의 개념과 활용

2.1 OOP의 기본 개념

객체 지향 프로그래밍(Object-Oriented Programming, OOP)은 현실 세계의 개념을 객체라는 단위로 모델링하여 소프트웨어를 개발하는 프로그래밍 패러다임이다. 객체는 **데이터(속성)**와 해당 데이터를 조작하는 **함수(메서드)**를 함께 묶어 하나의 단위로 관리한다.

OOP의 주요 특징:

  • 캡슐화(Encapsulation): 데이터와 함수를 하나의 객체로 묶어 외부로부터 데이터를 보호하고, 객체 내부의 구현 세부 사항을 숨긴다.
  • 상속(Inheritance): 기존 클래스의 속성과 메서드를 새로운 클래스가 물려받아 코드 재사용성을 높인다.
  • 다형성(Polymorphism): 동일한 인터페이스를 통해 다양한 객체를 처리할 수 있어 코드의 유연성을 높인다.
  • 추상화(Abstraction): 복잡한 시스템을 단순화하여 핵심적인 부분에 집중할 수 있게 한다.

2.2 강아지 멍멍 고양이 야옹

OOP의 개념을 한 번이라도 접해본 사람이라면 누구나 익숙한 동물 울음소리를 모델링하는 간단한 프로그램이다.

#include <iostream>
#include <vector>

// 추상 클래스: Animal
class Animal {
public:
    // 순수 가상 함수: 각 동물이 자신의 울음소리를 구현해야 함
    virtual void makeSound() const = 0;

    // 가상 소멸자: 상속받은 클래스의 소멸자를 올바르게 호출하기 위해 필요
    virtual ~Animal() = default;
};

// 구체적인 동물 클래스: Dog
class Dog : public Animal {
public:
    void makeSound() const override {
        std::cout << "멍멍" << std::endl;
    }
};

// 구체적인 동물 클래스: Cat
class Cat : public Animal {
public:
    void makeSound() const override {
        std::cout << "야옹" << std::endl;
    }
};

int main() {
    // Animal 포인터를 저장하는 벡터
    std::vector<Animal*> animals;

    // 동적 할당을 통해 객체 생성
    animals.push_back(new Dog());
    animals.push_back(new Cat());

    // 다형성을 활용하여 각 동물의 makeSound() 호출
    for (const auto& animal : animals) {
        animal->makeSound();
    }

    // 메모리 해제
    for (auto& animal : animals) {
        delete animal;
    }

    return 0;
}
  • Animal은 추상 클래스로, makeSound()라는 순수 가상 함수를 가진다.
  • Dog와 Cat 클래스는 Animal을 상속받아 makeSound()를 오버라이딩한다.
  • main() 함수에서는 Animal* 타입의 포인터를 저장하는 벡터를 사용하여 다양한 동물 객체를 관리한다.
  • 루프를 통해 각 동물의 makeSound()를 호출할 때 다형성이 적용되어 실제 객체의 메서드가 호출된다.

2.3 OOP가 넓은 범위에서 유용하게 사용된 이유

  • 현실 세계의 모델링 용이성: 현실의 사물과 개념을 객체로 표현하여 프로그래밍에 적용하기 쉽다.
  • 코드 재사용성 향상: 상속과 캡슐화를 통해 코드의 중복을 줄이고 유지보수를 용이하게 한다.
  • 유연한 코드 구조: 다형성을 통해 새로운 기능 추가나 변경에 유연하게 대응할 수 있다.
  • 캡슐화를 통한 안정성 확보: 객체 내부의 데이터를 보호하여 코드의 안정성과 신뢰성을 높인다.
  • 추상화를 통한 복잡성 관리: 복잡한 시스템을 단순화하여 핵심 로직에 집중할 수 있다.

3. 객체 지향 설계의 한계와 문제점

위에서 설명한 바와 같이 OOP는 많은 장점을 가지고 있지만, 특히 성능이 중요한 분야에서는 몇 가지 한계를 드러낸다.

3.1 메모리 비효율성

  • 메모리 산재: 객체들이 힙(Heap)에 동적 할당되어 메모리 상에서 연속적이지 않은 위치에 존재한다.
  • 캐시 미스(Cache Miss) 증가: 메모리 상에 산발적으로 위치한 데이터를 접근하면 CPU 캐시의 효율이 떨어져 성능 저하를 유발한다.

예시:

// 엔티티 클래스
class Entity {
public:
    virtual void update() = 0;
    virtual ~Entity() = default;
};

// 다양한 엔티티 객체 생성
std::vector<Entity*> entities;
entities.push_back(new Player());
entities.push_back(new Enemy());
// ...

// 엔티티 업데이트
for (auto& entity : entities) {
    entity->update();
}
  • 각 엔티티 객체는 힙 메모리에 동적으로 할당되며, 메모리 주소가 연속적이지 않다.
  • 루프를 통해 객체를 순회할 때 메모리 접근 패턴이 불규칙하여 캐시 미스가 발생한다.

3.2 동적 바인딩 오버헤드

  • 가상 함수 호출: 다형성을 구현하기 위해 가상 함수를 사용하며, 이는 런타임에 실제 함수가 결정되는 동적 바인딩을 필요로 한다.
  • 오버헤드 발생: 가상 함수 테이블(V-Table)을 참조하여 함수를 호출하는 과정에서 추가적인 메모리 접근과 연산이 발생한다.

3.3 데이터 접근의 비효율성

  • 캡슐화로 인한 제약: 객체 내부의 데이터를 보호하기 위해 private 또는 protected로 선언하여 직접 접근이 불가능하다.
  • 함수를 통한 간접 접근: 데이터에 접근하거나 변경하기 위해서는 접근자(getter)나 설정자(setter) 함수를 사용해야 하며, 이는 추가적인 함수 호출 오버헤드를 발생시킨다.

3.4 복잡성 증가

  • 과도한 상속 구조: 깊은 상속 계층은 코드를 이해하고 유지보수하는 데 어려움을 준다.
  • 디자인 패턴의 남용: 필요 이상으로 복잡한 디자인 패턴을 적용하면 오히려 코드의 가독성과 이해도를 떨어뜨린다.
  • 디버깅의 어려움: 다형성과 동적 바인딩으로 인해 버그의 원인을 추적하기 어려울 수 있다.

4. 데이터 중심 설계(DOD)의 등장과 필요성

4.1 DOD의 기본 개념

데이터 중심 설계(Data-Oriented Design, DOD)는 프로그램의 중심을 데이터에 두고, 데이터의 배치처리 방식을 최적화하여 성능을 극대화하는 프로그래밍 패러다임이다.

  • 데이터 주도 접근: 프로그램의 핵심은 데이터이며, 코드는 데이터를 변환하거나 이동시키는 도구로 본다.
  • 메모리 레이아웃 최적화: 데이터가 메모리에 연속적으로 배치되도록 설계하여 CPU 캐시의 효율을 높인다.
  • 일괄 처리 방식: 동일한 작업을 여러 데이터에 반복적으로 수행하여 분기(branch)를 최소화하고, 벡터화(vectorization)를 통한 병렬 처리를 용이하게 한다.
[OOP 방식]
+-----------------+    +-----------------+    +-----------------+
|   Entity 객체    |    |   Entity 객체    |    |   Entity 객체    |
| - position      |    | - position      |    | - position      |
| - velocity      |    | - velocity      |    | - velocity      |
| - update()      |    | - update()      |    | - update()      |
+-----------------+    +-----------------+    +-----------------+

[메모리 상의 배치]
객체1 위치: 메모리 주소 0x1A2B
객체2 위치: 메모리 주소 0x3C4D
객체3 위치: 메모리 주소 0x5E6F

[DOD 방식]
positions: [pos1][pos2][pos3][...]
velocities: [vel1][vel2][vel3][...]

4.2 DOD가 효과적인 분야

DOD가 효과적일 것으로 기대되는 분야는 대개, 하드웨어의 성능을 극한까지 끌어올려 더 빠르고 넓은 대역폭의 연산을 요구하는 분야이다.

  • 게임 개발: 수천에서 수백만 개의 엔티티를 실시간으로 업데이트해야 하는 상황에서 성능 향상이 필수적이다.
  • 그래픽 렌더링: 대량의 버텍스(vertex)와 픽셀(pixel) 데이터를 처리할 때 캐시 효율이 중요하다.
  • 과학 계산 및 시뮬레이션: 방대한 데이터를 고속으로 연산해야 하는 분야에서 유용하다.
  • 빅데이터 처리: 대용량 데이터의 분석과 처리에서 메모리 접근 패턴의 최적화가 성능을 좌우한다.

5. DOD의 실제 예제

5.1 DOD의 특징

예제를 탐구해보기에 앞서 DOD가 가진 특징을 다시 한 번 환기해보자.

CPU 캐시와 메모리 접근:

  • CPU 캐시는 메모리 접근 속도를 향상시키기 위한 작은 크기의 고속 메모리이다.
  • 캐시 히트(Cache Hit): 필요한 데이터가 캐시에 존재하여 빠르게 접근할 수 있는 경우.
  • 캐시 미스(Cache Miss): 필요한 데이터가 캐시에 없어서 메인 메모리에서 가져와야 하는 경우.

데이터의 연속성:

  • 데이터가 메모리에 연속적으로 배치되면 한 번의 메모리 접근으로 여러 데이터를 캐시에 로드할 수 있다.
  • 이를 통해 캐시 히트율을 높이고 성능을 향상시킬 수 있다.

벡터화(Vectorization):

  • SIMD(Single Instruction Multiple Data): 하나의 명령어로 여러 데이터를 동시에 처리하는 기술.
  • 루프 내의 연산이 단순하고 데이터가 연속적이면 컴파일러가 자동으로 벡터화하여 SIMD 명령어를 사용할 수 있다.

5.2 게임 엔진의 엔티티 업데이트 예제

목표: 다수의 엔티티의 위치를 매 프레임마다 효율적으로 업데이트한다.

5.2.1 OOP 방식의 구현

#include <vector>

// 엔티티 기본 클래스
class Entity {
public:
    virtual void update() = 0;
    virtual ~Entity() = default;
protected:
    float position;
    float velocity;
};

// 플레이어 클래스
class Player : public Entity {
public:
    void update() override {
        position += velocity;
        // 추가적인 플레이어 로직...
    }
};

// 적 클래스
class Enemy : public Entity {
public:
    void update() override {
        position += velocity;
        // 추가적인 적 로직...
    }
};

int main() {
    std::vector<Entity*> entities;

    // 엔티티 생성 및 추가
    for (int i = 0; i < 1000; ++i) {
        if (i % 2 == 0)
            entities.push_back(new Player());
        else
            entities.push_back(new Enemy());
    }

    // 매 프레임마다 업데이트
    for (auto& entity : entities) {
        entity->update(); // 가상 함수 호출
    }

    // 메모리 해제
    for (auto& entity : entities) {
        delete entity;
    }

    return 0;
}

위의 코드는 객체지향 프로그래밍이 익숙한 사람들에게는 더할나위 없이 편안한 코드일 것이다. 하지만 우리의 CPU는 생각이 좀 다르다.

  • 가상 함수 호출로 인한 오버헤드
    • 동적 바인딩 비용: entity->update() 호출 시 가상 함수 테이블(vtable)을 참조하여 실제 함수를 결정한다. 이 과정에서 추가적인 메모리 접근과 연산이 필요하며, 이는 CPU 파이프라인의 예측 불가능성을 높여 성능 저하를 유발한다.
    • 인라인화 불가: 컴파일러는 가상 함수를 인라인화할 수 없으므로, 함수 호출 오버헤드가 제거되지 않는다.
  • 메모리 비연속성으로 인한 캐시 효율 저하
    • 힙 메모리의 산재: new 연산자를 통해 동적으로 할당된 객체들은 메모리 상에서 연속적으로 배치되지 않을 수 있다.
    • 캐시 미스 증가: 엔티티 객체들이 메모리 전역에 흩어져 있으므로, 루프 내에서 entity->update()를 호출할 때마다 CPU 캐시에 로드되지 않은 메모리 영역에 접근하게 되어 캐시 미스가 발생한다.
  • 메모리 사용량 증가
    • 추가적인 메타데이터: 가상 함수 테이블 포인터(vptr) 등 객체 지향 특성으로 인해 각 객체의 메모리 크기가 증가한다.
    • 메모리 단편화: 동적 메모리 할당으로 인해 메모리 단편화가 발생하여 메모리 사용 효율이 떨어진다.
  • 복잡한 상속 구조로 인한 유지보수 어려움
    • 상속 계층의 증가: 다양한 엔티티 타입을 지원하기 위해 상속 구조가 깊어지면, 코드의 복잡도가 높아지고 이해하기 어려워진다.
    • 의도치 않은 동작: 상속 및 다형성으로 인해 발생하는 예기치 않은 버그를 디버깅하기 어렵다.

5.2.2 DOD 방식

#include <vector>
#include <cstddef>

// 엔티티 데이터 구조체
struct EntityData {
    std::vector<float> positions;
    std::vector<float> velocities;
};

// 엔티티 업데이트 함수
void updateEntities(EntityData& data) {
    size_t count = data.positions.size();

    // 루프 벡터화를 위해 단순한 연산 유지
    for (size_t i = 0; i < count; ++i) {
        data.positions[i] += data.velocities[i];
    }
}

int main() {
    EntityData entities;

    // 데이터 초기화 (예시로 1000개의 엔티티 생성)
    for (int i = 0; i < 1000; ++i) {
        entities.positions.push_back(static_cast<float>(i));
        entities.velocities.push_back(0.5f);
    }

    // 매 프레임마다 업데이트
    updateEntities(entities);

    return 0;
}

위의 구현을 살펴보면, 기존의 AoS(Array of Struct)에서 SoA(Struct of Array) 방식으로 변경된 것을 알 수 있다. 이를 통해 우리가 기대해볼 수 있는 장점은 다음과 같다.

  • 캐시 효율 향상
    • 연속적인 메모리 배치: positions와 velocities 벡터의 데이터는 메모리에 연속적으로 저장된다.
    • 캐시 히트율 증가: 연속된 메모리 접근으로 인해 CPU 캐시에 한 번에 여러 데이터가 로드되어 캐시 히트율이 높아진다.
    • 공간 지역성(Spatial Locality): 인접한 메모리 주소에 접근하므로 캐시의 효율이 극대화된다.
  • 오버헤드 감소
    • 가상 함수 제거: 동적 바인딩이 없으므로 함수 호출 오버헤드가 감소한다.
    • 인라인화 가능: updateEntities 함수는 컴파일러가 인라인화하여 함수 호출 오버헤드를 제거할 수 있다.
  • 컴파일러 최적화 용이
    • 루프 벡터화: 단순한 반복문은 컴파일러가 자동으로 SIMD 명령어를 사용하도록 최적화할 수 있다.
    • 분기 최소화: 복잡한 조건문이나 가상 함수 호출이 없어 CPU 파이프라인이 효율적으로 동작한다.
  • 메모리 사용량 감소
    • 필요한 데이터만 저장: 불필요한 메타데이터가 없으므로 메모리 사용량이 감소한다.
    • 메모리 단편화 감소: 동적 메모리 할당이 최소화되어 메모리 단편화가 줄어든다.
  • 데이터 처리의 단순화
    • 일괄 처리 가능: 동일한 연산을 대량의 데이터에 적용하기 쉽다.
    • 병렬 처리 용이: 데이터 간의 의존성이 낮아 멀티스레딩이나 GPU를 활용한 병렬 처리가 가능하다.

요약

  • 데이터 배치의 최적화캐시 효율 향상으로 이어지고, 이는 곧 프로그램 성능의 향상으로 연결된다.
  • 가상 함수 제거로 인해 동적 바인딩 오버헤드가 없어지고, 이는 CPU 파이프라인의 효율적 활용을 가능하게 한다.
  • 컴파일러 최적화의 용이성코드의 단순화에서 비롯되며, 이는 실행 속도 개선을 가져온다.

5.2.3 파티클 시스템 예제

목표: 대량의 파티클을 실시간으로 업데이트하며, 성능을 최적화한다.

OOP 방식:

class Particle {
public:
    void update() {
        position += velocity;
        life -= decay;
    }
private:
    float position;
    float velocity;
    float life;
    float decay;
};

std::vector<Particle> particles;

// 파티클 초기화
for (int i = 0; i < 100000; ++i) {
    particles.emplace_back(/* 초기값 */);
}

// 매 프레임마다 업데이트
for (auto& particle : particles) {
    particle.update();
}

그런데 위의 구현에는 아래와 같은 문제가 있다.

  • 메모리 비연속성
    • 객체의 크기 증가: 각 Particle 객체는 여러 멤버 변수를 포함하므로 크기가 크다.
    • 메모리 배치의 비효율성: std::vector<Particle>은 내부적으로 연속된 메모리를 사용하지만, 객체의 크기가 커서 캐시 라인에 더 적은 수의 객체가 들어간다.
    • 캐시 미스 증가: 루프 내에서 각 객체의 데이터를 접근할 때 캐시 미스가 발생할 가능성이 높아진다.
  • 함수 호출 오버헤드
    • 인라인화 제한: 멤버 함수 update()는 인라인화되지 않을 수 있으며, 함수 호출 오버헤드가 발생한다.
  • 컴파일러 최적화 어려움
    • 복잡한 데이터 구조: 객체 내부의 데이터에 대한 의존성이 높아 컴파일러가 벡터화 최적화를 수행하기 어렵다.
  • 메모리 사용량 증가
    • 불필요한 데이터 저장: 모든 파티클이 동일한 속성을 가지는 경우에도 각 객체마다 데이터를 저장하여 메모리 낭비가 발생한다.

DOD 방식:

struct ParticleData {
    std::vector<float> positions;
    std::vector<float> velocities;
    std::vector<float> lives;
    std::vector<float> decays;
};

void updateParticles(ParticleData& data) {
    size_t count = data.positions.size();

    // 루프 벡터화를 위해 단순한 연산 유지
    for (size_t i = 0; i < count; ++i) {
        data.positions[i] += data.velocities[i];
        data.lives[i] -= data.decays[i];
    }
}

ParticleData particles;

// 파티클 초기화
for (int i = 0; i < 100000; ++i) {
    particles.positions.push_back(/* 초기값 */);
    particles.velocities.push_back(/* 초기값 */);
    particles.lives.push_back(/* 초기값 */);
    particles.decays.push_back(/* 초기값 */);
}

// 매 프레임마다 업데이트
updateParticles(particles);

이렇게 구현을 변경했을 때 우리가 얻을 수 있을 것으로 기대되는 장점은 다음과 같다.

  • 메모리 접근 효율 극대화
    • 데이터의 연속성: 각 속성별로 데이터를 연속된 메모리에 저장하여 캐시 효율을 높인다.
    • 캐시 히트율 향상: 루프 내에서 인접한 메모리 주소를 순차적으로 접근하므로 캐시 미스가 감소한다.
  • 벡터화 최적화 가능
    • 단순한 연산 구조: 각 속성별로 동일한 연산을 수행하므로 SIMD 명령어를 사용한 벡터화가 가능하다.
    • 컴파일러 최적화 지원: 컴파일러가 자동으로 루프를 벡터화하여 성능을 향상시킨다.
  • 메모리 사용량 최적화
    • 필요한 데이터만 저장: 파티클별로 필요한 속성만 저장하여 메모리 낭비를 줄인다.
    • 메모리 단편화 최소화: 동적 할당을 최소화하여 메모리 관리 효율을 높인다.
  • 병렬 처리 용이
    • 데이터 독립성: 파티클 간의 데이터 의존성이 없으므로 멀티스레딩이나 GPU를 활용한 병렬 처리가 가능하다.
    • 스케일링 가능성: 데이터 양이 증가해도 병렬화를 통해 성능 저하를 최소화할 수 있다.
  • 함수 호출 오버헤드 감소
    • 단일 함수 사용: updateParticles 함수 하나로 모든 파티클을 업데이트하여 함수 호출 오버헤드를 줄인다.
    • 인라인화 가능성: 단순한 함수 구조로 인해 컴파일러가 인라인화하여 성능을 개선할 수 있다.

6. 복습

이번 글에서 특히 중요한 용어들을 다시 한 번 짚고 넘어가자.

  • CPU 캐시(Cache)
    • 정의: CPU와 메인 메모리 사이에 위치한 고속 메모리로, 자주 사용되는 데이터를 저장하여 메모리 접근 속도를 향상시킨다.
    • 계층 구조: 일반적으로 L1, L2, L3 캐시로 구성되며, L1이 가장 빠르고 용량이 적다.
    • 중요성: 프로그램의 성능은 캐시 히트율에 크게 영향을 받으며, 캐시 효율을 높이는 것이 성능 최적화의 핵심이다.
  • 캐시 미스(Cache Miss)
    • 정의: CPU가 필요한 데이터를 캐시에서 찾지 못하고 메인 메모리에서 가져와야 하는 상황.
    • 종류:
      • Cold Miss: 처음 접근하는 데이터로 인해 발생.
      • Conflict Miss: 캐시의 한정된 크기로 인해 발생하는 충돌로 인한 미스.
      • Capacity Miss: 캐시 용량이 부족하여 발생.
    • 영향: 캐시 미스가 발생하면 메모리 접근 지연이 생겨 프로그램의 성능이 저하된다.
  • 동적 바인딩(Dynamic Binding)
    • 정의: 프로그램 실행 중에 함수 호출이 결정되는 방식으로, 가상 함수를 통해 구현된다.
    • 가상 함수 테이블(V-Table): 클래스의 가상 함수 주소를 저장하는 테이블로, 동적 바인딩 시 사용된다.
    • 오버헤드: 동적 바인딩은 추가적인 메모리 접근과 연산을 필요로 하여 성능에 부정적인 영향을 줄 수 있다.
  • 벡터화(Vectorization)
    • 정의: 하나의 명령어로 여러 데이터를 동시에 처리하는 기술로, SIMD 명령어를 활용한다.
    • SIMD(Single Instruction Multiple Data): 동일한 연산을 여러 데이터에 동시에 수행하는 병렬 처리 방식.
    • 장점: 벡터화를 통해 연산 속도를 크게 향상시킬 수 있다.
    • 조건: 데이터가 연속적이고, 연산이 단순하며, 데이터 간 의존성이 없어야 한다.
  • 메모리 단편화(Memory Fragmentation)
    • 정의: 메모리 할당과 해제가 반복되면서 사용되지 않는 작은 메모리 조각들이 생겨 전체 메모리 사용 효율이 떨어지는 현상.
    • 종류:
      • 외부 단편화: 사용되지 않는 메모리 블록들이 흩어져 있어 큰 메모리 요청을 처리할 수 없는 경우.
      • 내부 단편화: 할당된 메모리 블록 내에 사용되지 않는 공간이 남아 있는 경우.
    • 해결 방법: 메모리 풀(Memory Pool) 사용, 메모리 할당 전략 개선 등이 있다.
  • 공간 지역성(Spatial Locality)
    • 정의: 한 번 접근한 메모리 주소 근처의 주소를 곧바로 다시 접근하는 경향.
    • 중요성: 공간 지역성을 높이면 캐시 효율이 향상되어 프로그램 성능이 개선된다.
    • 관련 개념: 시간 지역성(Temporal Locality)은 동일한 메모리 주소를 짧은 시간 내에 반복해서 접근하는 경향을 말한다.
  • 인라인화(Inlining)
    • 정의: 함수 호출을 함수의 본체로 대체하여 함수 호출 오버헤드를 제거하는 컴파일러 최적화 기법.
    • 조건: 함수가 충분히 작고, 컴파일러가 인라인화가 성능에 도움이 된다고 판단할 때.
    • 제한 사항: 가상 함수나 재귀 함수는 인라인화가 불가능하거나 제한적이다.

이러한 용어들은 이번 글에서 데이터 중심 설계(DOD)의 중요성을 이해하는 데 핵심적인 개념들이다. 특히 CPU 캐시의 작동 원리캐시 효율이 프로그램 성능에 미치는 영향을 잘 이해해야 DOD의 장점을 최대한 활용할 수 있다.


7. 앞으로의 방향

이번 글을 통해 DOD가 가질 수 있는 기술적인 이점을 조명해보았다. 앞으로 이 시리즈를 통해 기존에 흔히 알려진 OOP 방식의 프로그래밍이 자칫 재앙적인 성능 하락을 일으킬 수 있는 부분들을 찾아 자세히 살펴보고, DOD관점에서 재설계를 해보며 다양한 실험들을 해보고자 한다. 이 과정을 통해 한 사람의 프로그래머로써 우리가 개발한 프로그램이 실행되는 하드웨어와 보다 더 친해지고, 또한 프로그램의 성능을 한계까지 끌어올리는 짜릿한 엔지니어링의 시간을 가져볼 것이다. 

'개발 > C++' 카테고리의 다른 글

CppCon 2014: "Data-Oriented Design and C++"  (2) 2024.10.15

이전 챕터에서 JUCE로 간단하게 윈도우를 생성하고 이를 구현하는 내부 코드들을 간단히 살펴보았다. 이번 챕터에서는 JUCE에서 기본적으로 제공하는 GUI Component들에 대해 살펴보고 이것들의 내부 코드 또한 하나씩 살펴보는 시간을 갖고자 한다. 

GUI Component란 Button, Slider, ComboBox, TableView, TreeView등의 Graphic User Interface 구성요소를 통틀어 말하는데 GUI 프로그래밍을 지원하는 Framework라면 기본적으로 이 Component를 구현해놓고 있다. JUCE Framework 역시 굉장히 다양하고 훌륭한 GUI Component들을 제공하는데 이것들의 코드를 살펴보고 어떻게 동작하는지 구조를 간략히 파악해보면서 JUCE Framework 이해에 한걸음 더 다가가 보고자 한다.


1) Projucer를 이용한 GUI Component 배치

첫번째 챕터를 통해 Projucer를 이용한 IDE Project 파일을 만드는 것을 실습해보았는데, 사실 Projucer에는 이것 말고도 훌륭한 기능이 한 가지 더있다! 바로 WYSIWYG 스타일로 즉, 코드를 치지 않고도 Drag&Drop만으로도 GUI Component를 배치할 수 있다는 것이다. 원하는 Component를 선택해서 원하는 위치에 배치하면 Projucer가 알아서 그에 해당하는 C++코드를 생성해준다. 설명이 길면 재미없으니깐 얼른 만지작거리러 가보자.




File explorer 탭을 열고 오른쪽 버튼을 클릭하면 다음과 같은 항목들이 등장한다. 우리는 여기서 두번째 블록 맨 아래의 [Add New GUI Component...] 라는 항목을 선택할 것이다. 그러면 새로운 소스파일을 저장할 경로를 선택하는 창이 뜰 것이다. Source 폴더의 아래로 들어가 원하는 이름을 붙여주자. 나는 NewComponent라는 이름을 붙이기로 했다.

그러면 자동으로 우리가 설정한 이름을 가진 헤더파일과 CPP파일이 각각 생성된 것이 보인다. 재밌는 점은 CPP파일을 선택하면 코드를 보여주지 않고 이런 탭메뉴를 보여준다는 것이다. 첫번째 탭은 일단 건너 뛰고 빨리 두번째 SubComponent Tab을 열어보자 빨리빨리.


여기서 오른쪽 마우스를 클릭



모눈종이같은 공간을 오른쪽 클릭하면 우리가 여기에 추가할 수 있는 GUI Component의 종류들이 좕 뜬다. 가장 위에 있는 New Text Button을 클릭해서 새로운 Text Button Component를 만들어보자. 그러면 우리의 첫 GUI Component가 드디어 세상의 빛을 보게 될 것이다. 이렇게 만들어진 button에 대한 속성은 오른쪽에 모두 나타나있다. member name은 코드 상에서 이 버튼에 접근할 수 있는 이름이고 name은 이 button의 속성으로 저장되어 Runtime에서 접근할 수 있는 정보이다. virtual class는 이 Component의 선언 타입인데, 따로 값을 입력하지 않으면 기본값으로 TextButton 이라는 타입으로 선언이 된다. 이 아래로는 모두 이 버튼의 외관을 결정하는 속성들이니 하나씩 건드려보며 어떻게 변하는지 직접 느껴보면 좋다.


지금까지의 상태를 간단히 짚고 넘어가자면, 우리는 NewComponent라는 객체를 새롭게 만들었고 그 안에 TextButton 객체를 추가했다. NewComponent와 TextButton는 서로가 부모 자식 관계에 놓인 객체인 것이다. 그렇다면 저 오른쪽 속성창에 있는 Generate ButtonListener라는 체크박스는 무엇을 뜻할까? 이 TextButton이 눌렸을 때 발생하는 Event를 부모객체인 NewComponent가 수신하도록 한다는 뜻이다. 그림으로 표현하자면 이러하다.

0xFF00CA80은 아무렇게나 적어본 NewComponent의 메모리 주소이다.


Button은 NewComponent의 멤버 중 하나인데, 이때 Button은 buttonListeners라는 List에 자신의 부모 즉, NewComponent의 포인터를 담고 있다. Button이 사용자로부터 mouseDown Event를 받으면 buttonListeners의 모든 멤버가 buttonClicked 메서드를 호출하도록 되어있다. 그리고 이 과정은 Button에서 mouseDown이 발생하는 순간 Synchronous하게 이뤄진다. 이 동작에 대해서는 아래에서 코드를 보며 다시 한 번 살펴보겠다. 일단은 NewComponent에 대해 좀 더 자세한 설명을 위해 급하게 건너뛰었던 첫번째 탭으로 돌아가보겠다.



첫번째 탭인 'Class'에서는 이 Component의 전반적인 속성을 설정할 수 있다. Class name은 이 Component의 실제 이름을 가리키며, 코드상에서 접근할 수 있는 이름이다. Template file에는 이러한 설정 등을 통해 자동으로 생성되는 코드의 Template을 설정한 file 경로를 입력할 수 있는데 기본적으로도 이미 Template이 잘 갖춰져 있기 때문에 필수적이지는 않다. Component name은 이 Component가 내부적으로 갖게되는 이름인데 Attribute로 지니고 있기 때문에 Runtime에서도 접근할 수 있는 정보이다.


그 다음에 있는 Parent classes라는 이름을 주목해보자. 단수가 아닌 복수로 쓰인 이유는 바로 NewComponent가 이 항목에 입력되는 Class들을 모두 상속받게 된다는 뜻이다. 지금은 'Component'라는 클래스만을 상속받도록 되어있지만 콤마(,)를 이용해 더 추가할 수도 있다. 이것을 이해했다면 아래의 두 항목도 용도가 쉽게 유추된다. Constructor params는 이 Component Class의 생성자가 받을 인자들에 대한 정보이고, Member initialisers는 생성자 호출시 초기화 해줄 멤버를 입력하는 곳이다.


그리고 아래의 나머지는 이 Component의 높이와 너비를 입력하고 크기를 고정시킬건지 유동적으로 변화시킬 것인지를 설정하는 곳이다.


오른쪽에 있는 리스트들 또한 매우 흥미를 당긴다. 이름을 통해 유추해보자면, 이 Component에 어떤 이벤트가 발생했을 때의 처리여부와 그 때 실행할 콜백함수에 대한 정보가 담겨있다. 마우스가 눌렸을 때, 다른 Component들과의 토폴로지가 변경됐을 때, 자식 Component에 변화가 발생했을 때 등 엄청나게 많은 Event들을 등록할 수 있게 되어있다. 이 NewComponent 객체가 이전 탭에서 추가했던 ButtonListener이외에도 수많은 Event들을 수신할 수 있다는 뜻이다. 이것들만 잘 이용해도 상당히 동적인 반응형 어플리케이션을 구현할 수 있다. 이 Event를 처리하는 것에 대해서도 역시 이후에 좀 더 자세히 다뤄보겠다.


일단은 이 버튼이 추가된 창을 빨리 실행해보고 싶을 뿐이다. 상단의 Xcode(혹은 Visual Studio 등)의 아이콘을 눌러 IDE로 넘어간 뒤 빌드하고 실행을 해보자.



버튼을 추가했지만 어째선지 윈도우에는 변함없이 인사만 건네고 있다. 우리가 만들었던 버튼은 어디로 가있는 걸까? 결론부터 말하자면 버튼이 코드 상으로는 존재하지만 실제 윈도우 위에 로드를 하지 않았기 때문에 보이지 않는 것이다. 이제 우리가 지난 챕터에서 모른척 하고 어물쩡 넘어가버렸던 Main.cpp 파일을 열어볼 차례이다.


/*
  ==============================================================================

    This file was auto-generated!

    It contains the basic startup code for a JUCE application.

  ==============================================================================
*/

#include "../JuceLibraryCode/JuceHeader.h"
#include "MainComponent.h" // -> "NewComponent.h"

//==============================================================================
class JUCEPractice1Application  : public JUCEApplication
{
public:
    //==============================================================================
    JUCEPractice1Application() {}

    const String getApplicationName() override       { return ProjectInfo::projectName; }
    const String getApplicationVersion() override    { return ProjectInfo::versionString; }
    bool moreThanOneInstanceAllowed() override       { return true; }

    //==============================================================================
    void initialise (const String& commandLine) override
    {
        // This method is where you should put your application's initialisation code..

        mainWindow = new MainWindow (getApplicationName());
    }

    void shutdown() override
    {
        // Add your application's shutdown code here..

        mainWindow = nullptr; // (deletes our window)
    }

    //==============================================================================
    void systemRequestedQuit() override
    {
        // This is called when the app is being asked to quit: you can ignore this
        // request and let the app carry on running, or call quit() to allow the app to close.
        quit();
    }

    void anotherInstanceStarted (const String& commandLine) override
    {
        // When another instance of the app is launched while this one is running,
        // this method is invoked, and the commandLine parameter tells you what
        // the other instance's command-line arguments were.
    }

    //==============================================================================
    /*
        This class implements the desktop window that contains an instance of
        our MainComponent class.
    */
    class MainWindow    : public DocumentWindow
    {
    public:
        MainWindow (String name)  : DocumentWindow (name,
                                                    Desktop::getInstance().getDefaultLookAndFeel()
                                                                          .findColour (ResizableWindow::backgroundColourId),
                                                    DocumentWindow::allButtons)
        {
            setUsingNativeTitleBar (true);
            setContentOwned (new MainComponent(), true);// -> setContentOwned (new NewComponent(), true);

            centreWithSize (getWidth(), getHeight());
            setVisible (true);
        }

        void closeButtonPressed() override
        {
            // This is called when the user tries to close this window. Here, we'll just
            // ask the app to quit when this happens, but you can change this to do
            // whatever you need.
            JUCEApplication::getInstance()->systemRequestedQuit();
        }

        /* Note: Be careful if you override any DocumentWindow methods - the base
           class uses a lot of them, so by overriding you might break its functionality.
           It's best to do all your work in your content component instead, but if
           you really have to override any DocumentWindow methods, make sure your
           subclass also calls the superclass's method.
        */

    private:
        JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MainWindow)
    };

private:
    ScopedPointer<MainWindow> mainWindow;
};

//==============================================================================
// This macro generates the main() routine that launches the app.
START_JUCE_APPLICATION (JUCEPractice1Application)

이전까지 봤던 JUCE 코드들과는 다르게 뭔가 무시무시하다. 하지만 겁먹을 필요는 없다. 우리가 신경써야 할 라인은 12번째 줄과 69번째 줄 뿐이다. 12)에서는 앞서 만들어져 있던 MainComponent에 대한 정보가 담긴 헤더파일을 코드에 추가하고, 69)에서는 앞서 선언된 MainComponent를 생성하고 있다. 이 코드들만 각각 NewComponent로 교체해주면 된다. 이제 다시 빌드하고 실행을 해보자.



짜잔잔 버튼이 생겨났다. 클릭해도 아무 일은 일어나지 않지만 그래도 재밌고 신기하다. 이제 저 버튼이 생겨나기까지 코드에서는 무슨 일이 있었는지 알아보러 얼른 가보자.


2) NewComponent.h

/*
  ==============================================================================

  This is an automatically generated GUI class created by the Projucer!

  Be careful when adding custom code to these files, as only the code within
  the "//[xyz]" and "//[/xyz]" sections will be retained when the file is loaded
  and re-saved.

  Created with Projucer version: 5.3.1

  ------------------------------------------------------------------------------

  The Projucer is part of the JUCE library.
  Copyright (c) 2017 - ROLI Ltd.

  ==============================================================================
*/

#pragma once

//[Headers]     -- You can add your own extra header files here --
#include "../JuceLibraryCode/JuceHeader.h"
//[/Headers]



//==============================================================================
/**
                                                                    //[Comments]
    An auto-generated component, created by the Projucer.

    Describe your class and how it works here!
                                                                    //[/Comments]
*/
class NewComponent  : public Component,
                      public Button::Listener
{
public:
    //==============================================================================
    NewComponent ();
    ~NewComponent();

    //==============================================================================
    //[UserMethods]     -- You can add your own custom methods in this section.
    //[/UserMethods]

    void paint (Graphics& g) override;
    void resized() override;
    void buttonClicked (Button* buttonThatWasClicked) override;



private:
    //[UserVariables]   -- You can add your own custom variables in this section.
    //[/UserVariables]

    //==============================================================================
    ScopedPointer<TextButton> textButton; // std::unique_ptr<TextButton> textButton;


    //==============================================================================
    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (NewComponent)
};

//[EndFile] You can add extra defines here...
//[/EndFile]

NewComponent의 헤더파일을 열어보자. 우선은 Component 클래스만 상속 받다가 Button::Listener 클래스도 추가로 상속받는 것을 확인해볼 수 있다. 더불어 Button::Listener에 선언되어 있는 buttonClicked 메소드도 재정의하도록 추가됐다. 그리고 우리가 화면에 새롭게 배치했던 textButton 멤버도 private영역에 새롭게 선언되어 있다. 흥미로운 것은 TextButton이 ScopedPointer의 Generic Type으로 선언되어 있다는 점인데, ScopedPointer 클래스는 C++표준에서 제공하는 스마트포인터와 같은 역할을 한다. 그래서 그런지는 몰라도 가장 최신버전인 JUCE 5.3.2버전 부터 Deprecate되어 더 이상 사용되지 않는다. 하지만 이 튜토리얼은 5.3.1버전으로 작성되었기 때문에 일단은 저것이 스마트포인터와 똑같다는 것만 알고 넘어가면 되겠다.


스마트포인터로 버튼을 선언한 이유는 간단하다. NewComponent에 속해있는 객체이기 때문에 NewComponent가 소멸되면 TextButton객체 역시 더 이상 어디에도 필요가 없기 때문에 Composition 형태로 NewComponent에 종속시키려는 목적이다. 처음부터 포인터 타입이 아니라 일반 멤버 변수로 선언하면 ('TextButton textButton;' 이런식으로) 어차피 NewComponent가 소멸될 때 알아서 메모리가 해제되지 않겠냐는 질문을 던져볼 수도 있겠다. 그것에 대한 대답은 NewComponent.cpp에 담겨있다.


3) NewComponent.cpp

/*
  ==============================================================================

  This is an automatically generated GUI class created by the Projucer!

  Be careful when adding custom code to these files, as only the code within
  the "//[xyz]" and "//[/xyz]" sections will be retained when the file is loaded
  and re-saved.

  Created with Projucer version: 5.3.1

  ------------------------------------------------------------------------------

  The Projucer is part of the JUCE library.
  Copyright (c) 2017 - ROLI Ltd.

  ==============================================================================
*/

//[Headers] You can add your own extra header files here...
//[/Headers]

#include "NewComponent.h"


//[MiscUserDefs] You can add your own user definitions and misc code here...
//[/MiscUserDefs]

//==============================================================================
NewComponent::NewComponent ()
{
    //[Constructor_pre] You can add your own custom stuff here..
    //[/Constructor_pre]

    addAndMakeVisible (textButton = new TextButton ("new button"));
    textButton->addListener (this);

    textButton->setBounds (225, 188, 150, 24);


    //[UserPreSize]
    //[/UserPreSize]

    setSize (600, 400);


    //[Constructor] You can add your own custom stuff here..
    //[/Constructor]
}

NewComponent::~NewComponent()
{
    //[Destructor_pre]. You can add your own custom destruction code here..
    //[/Destructor_pre]

    textButton = nullptr;


    //[Destructor]. You can add your own custom destruction code here..
    //[/Destructor]
}

//==============================================================================
void NewComponent::paint (Graphics& g)
{
    //[UserPrePaint] Add your own custom painting code here..
    //[/UserPrePaint]

    g.fillAll (Colour (0xff323e44));

    //[UserPaint] Add your own custom painting code here..
    //[/UserPaint]
}

void NewComponent::resized()
{
    //[UserPreResize] Add your own custom resize code here..
    //[/UserPreResize]

    //[UserResized] Add your own custom resize handling here..
    //[/UserResized]
}

void NewComponent::buttonClicked (Button* buttonThatWasClicked)
{
    //[UserbuttonClicked_Pre]
    //[/UserbuttonClicked_Pre]

    if (buttonThatWasClicked == textButton)
    {
        //[UserButtonCode_textButton] -- add your button handler code here..
        //[/UserButtonCode_textButton]
    }

    //[UserbuttonClicked_Post]
    //[/UserbuttonClicked_Post]
}



//[MiscUserCode] You can add your own definitions of your custom methods or any other code here...
//[/MiscUserCode]
#endif


//[EndFile] You can add extra defines here...
//[/EndFile]

생성자의 코드의 첫 줄을 살펴보면 addAndMakeVisible이라는 함수를 호출과 동시에 textButton에 메모리를 할당하는 것을 볼 수 있다. 그리고 추가적으로 textButton의 초기화를 위한 작업들이 아래에 이어 등장한다. 클래스 타입의 멤버를 포인터 변수로 선언했을 때의 장점은 초기화 시점을 개발자가 직접 정할 수 있다는 점이다. 지금은 NewComponent 안에 TextButton Component만 있지만 여러개의 하위 Component들이 선언되어 있고 이들이 서로 어떠한 종속관계를 맺어야 한다면 생성자에 모든 것을 맡기기 보다는 비교적 덜 안전하더라도 개발자가 직접 초기화 시점을 정할 수 있도록 스마트포인터 형태로 만들어 주는 것이다. 그래도 56)을 보면 코드의 명료함을 위해 소멸자에서 스마트포인터의 메모리를 수동으로 해제하고 있다.


35)에서는 textButton->addListener(this)라는 함수를 통해 textButton이 어떤 Event를 받았을 때, 그 Event를 받았다는 사실을 알려주도록 NewComponent를 textButton의 Listener에 등록하고 있다. 그렇다면 이것이 어떻게 버튼이 눌렸을 때 NewComponent의 buttonClicked가 호출되도록 동작하는 것일까? Button의 내부 코드를 한번 쫒아가 보자.


/* juce_Button.cpp */
void Button::mouseDown (const MouseEvent& e)
{
    updateState (true, true);

    if (isDown())
    {
        if (autoRepeatDelay >= 0)
            callbackHelper->startTimer (autoRepeatDelay);

        if (triggerOnMouseDown)
            internalClickCallback (e.mods); // <-- 여러 복잡한 과정을 지나 아무튼 이 함수가 호출된다.
    }
}

Button은 Component 클래스를 상속받아서 마우스가 눌릴 때 불리는 mouseDown Callback을 재정의 했다.

/* juce_Button.cpp */
void Button::internalClickCallback (const ModifierKeys& modifiers)
{
    if (clickTogglesState)
    {
        const bool shouldBeOn = (radioGroupId != 0 || ! lastToggleState);

        if (shouldBeOn != getToggleState())
        {
            setToggleState (shouldBeOn, sendNotification);
            return;
        }
    }

    sendClickMessage (modifiers); // <-- 그랬더니 또 이런저런 복잡한 과정을 지나 여차저차 이 함수가 호출된다.
}
/* juce_Button.cpp */
void Button::sendClickMessage (const ModifierKeys& modifiers)
{
    Component::BailOutChecker checker (this);

    if (commandManagerToUse != nullptr && commandID != 0)
    {
        ApplicationCommandTarget::InvocationInfo info (commandID);
        info.invocationMethod = ApplicationCommandTarget::InvocationInfo::fromButton;
        info.originatingComponent = this;

        commandManagerToUse->invoke (info, true);
    }

    clicked (modifiers);

    if (checker.shouldBailOut())
        return;

    buttonListeners.callChecked (checker, [this] (Listener& l) { l.buttonClicked (this); }); //<-- 이곳에서 buttonListener 안에 등록된 Listener들의 buttonClicked를 호출해준다.

    if (checker.shouldBailOut())
        return;

    if (onClick != nullptr)
        onClick();
}

buttonListeners는 addListener를 통해 인자로 넘어온 것들을 저장하는 List 타입의 멤버이다. sendClickMessage의 20)에서 buttonListeners 안의 멤버가 모두 buttonClicked를 호출하도록 하는 것을 확인할 수 있다. 이 때 Callback 인자로 자신을 넘겨주는 것을 확인할 수 있다. 그 이유는 아래에서 설명하도록 하겠다.



36)에서는 textButton이 newComponent에서 어느 곳에 위치하는지 왼쪽 위의 좌표(x,y)와 높이 너비(width, height)를 차례로 설정해준다. 이전 편에서 잠시 살펴봤던 resized를 떠올려보자. 

virtual void Component::resized ( ) 

A component can implement this method to do things such as laying out its child components when its width or height changes. The method is called synchronously as a result of the setBounds or setSize methods, so repeatedly changing a components size will repeatedly call its resized method (unlike things like repainting, where multiple calls to repaint are coalesced together). If the component is a top-level window on the desktop, its size could also be changed by operating-system factors beyond the application's control.

이 Callback은 setBounds나 setSize가 호출되었을 때 syncronous하게 실행된다고 하였다. 36)이 실행되었을 때 역시 자동으로 textButton의 resize가 이어서 실행될 것임을 예상할 수 있다.


4) Event 등록

버튼도 만들어 봤으니 이제는 버튼이 눌렸을 때(Event) 실제로 어떤 일이 일어나도록(Callback) 해보겠다. Button이 눌린 횟수를 기록해서 화면에 표시해주는 기능을 만들어볼 것이다. 우선은 다시 Projucer로 돌아가보자.

이번에는 Text Button이 아닌 Label을 새로 추가해보겠다. stateLabel이라는 멤버 이름을 정해주고 기본 값으로 'Nothing Happend'라는 String을 띄워주도록 설정한다. 이것이 코드로는 다음과 같이 반영되어 있을 것이다. Label Component가 추가되면서 어떤 코드가 새로 추가됐는지 유심히 살펴보길 바란다.


/* NewComponent.h */ class NewComponent : public Component, public Button::Listener { public: //============================================================================== NewComponent (); ~NewComponent(); //============================================================================== //[UserMethods] -- You can add your own custom methods in this section. //[/UserMethods] void paint (Graphics& g) override; void resized() override; void buttonClicked (Button* buttonThatWasClicked) override; private: //[UserVariables] -- You can add your own custom variables in this section.     int cnt; //[/UserVariables] //============================================================================== ScopedPointer<TextButton> textButton; ScopedPointer<Label> stateLabel; //============================================================================== JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (NewComponent) };

/* NewComponent.cpp */
NewComponent::NewComponent ()
{
    //[Constructor_pre] You can add your own custom stuff here..
    //[/Constructor_pre]

    addAndMakeVisible (textButton = new TextButton ("new button"));
    textButton->addListener (this);

    textButton->setBounds (225, 188, 150, 24);

    addAndMakeVisible (stateLabel = new Label ("new label",
                                               TRANS("Nothing Happend")));
    stateLabel->setFont (Font (15.00f, Font::plain).withTypefaceStyle ("Regular"));
    stateLabel->setJustificationType (Justification::centredLeft);
    stateLabel->setEditable (false, false, false);
    stateLabel->setColour (TextEditor::textColourId, Colours::black);
    stateLabel->setColour (TextEditor::backgroundColourId, Colour (0x00000000));

    stateLabel->setBounds (224, 152, 150, 24);


    //[UserPreSize]
    //[/UserPreSize]

    setSize (600, 400);


    //[Constructor] You can add your own custom stuff here..
    cnt = 0;
    //[/Constructor]
}

버튼이 눌린 횟수를 기록하기 위해 헤더파일과 소스파일의 22)와 30)에 integer 변수 cnt를 선언했다. 주의해야 할 점은 이런 template Block의 바깥에 코드를 작성하면 Projucer에서 코드를 자동으로 생성할 때 모두 지워진다는 것이다. 나도 처음에는 이걸 모르고 Projucer를 사용했다가 여러번 낭패를 봤었다. 친절하게도 Block이 굉장히 세분화돼서 만들어져 있기 때문에 규칙만 잘 지키면 얼마든지 자유롭게 코딩할 수 있다. 이제는 버튼의 Event가 발생했을 때 호출되는 Callback 함수 buttonClicked로 가보자.


void NewComponent::buttonClicked (Button* buttonThatWasClicked)
{
    //[UserbuttonClicked_Pre]
    //[/UserbuttonClicked_Pre]

    if (buttonThatWasClicked == textButton)
    {
        //[UserButtonCode_textButton] -- add your button handler code here..
        cnt++;
        stateLabel->setText("Clicked " + String(cnt) + " times");
        //[/UserButtonCode_textButton]
    }

    //[UserbuttonClicked_Post]
    //[/UserbuttonClicked_Post]
}

NewComponent는 textButton이외에도 다른 ButtonListener에 등록될 수 있다. 현재는 textButton 하나에만 등록되어 있지만 만약에 버튼이 여러개가 있고 그 버튼 모두의 Event에 Listener로 등록되더라도 모두 공통적으로 buttonClicked라는 이 Callback을 호출할 것이다. 그렇기 때문에 Callback 인자로 어느 버튼이 눌린 것인지 알려주고 있다.

이를 통해 NewComponent가 가진 Button 멤버들과 메모리 주소를 비교하여 어떤 버튼인지 식별한 뒤 각각에 해당하는 동작을 수행하는 것이다.  6)에서도 이와 같은 방법으로 textButton임을 식별하여 9)눌린 횟수를 증가시켜주고 10)stateLabel의 텍스트를 업데이트한다. 이제 준비는 다 되었다. 빌드를 한 뒤 실행해서 잘 동작하는 것을 확인해보자.


열심히 누른 만큼 카운터도 정직하게 올라간다. 세상 모든 일이 이렇게 정직하면 좋으련만.


시작에 앞서 앞으로 사용하게 될 JUCE Framework(이하 JUCE)에 대해 간단히 살펴보는 시간을 가지겠다. JUCE는 ROLI라는 영국의 악기 만드는 회사가 소유권을 갖고 있고, Julian Storer라는 개발자가 리드 프로그래머로 있는 C++ Framework이다. 기본적으로 Audio Programming에 특화되어 있으며 각종 UI Component들과 내부적으로 잘 짜여진 데이터 구조를 지원한다. 또한 기본적으로 여러 OS에서 동작할 수 있도록 크로스플랫폼 빌드를 지원한다. 


현재도 활발히 업데이트가 되고 있는데 요즘은 업데이트 속도도 점점 빨라지는 것 같다. 나는 지금까지 만 2년 정도 이 Framework을 사용했는데 처음 접했을 당시 4.1버전이었고 지금은 벌써 5.3 버전까지 올라와있다. ROLI에서는 JUCE를 널리 알리기 위해 오디오 개발자 컨퍼런스도 ADC라는 이름으로 매년 런던에서 개최하고 있다.


ROLI의 대표 악기인 Seaboard는 라라랜드에서도 라이언 고슬링이 연주하는 장면이 등장한다. ADC2017에 갔었을 때 만져볼 기회가 있었는데 생각보다 세게 눌러야 하고, 건반을 두드리는 것보다는 문지르듯이 연주해야 한다.


JUCE는 일단 기본적으로 Audio Programming을 위한 여러가지 강력한 기능들을 제공한다. Class 몇 개를 간단히 조합하면 단순한 Audio Player도 금방 만들 수 있고 Sample 단위로도 신호처리 알고리즘을 바로 적용해볼 수 있도록 설계 되어 있어서 DSP를 공부하는 C++ 개발자들에게 매우 훌륭한 Framework이다. 또한 같은 코드로 여러 타입의 Audio Plugin을 개발할 수 있도록 Wrapper 역시 제공하는데, 이 덕분에 여러 Plugin 회사들이 JUCE를 이용해서 VST, AAX, AU 타입 제품을 빠른 시간 내에 개발할 수 있게 되었다. 


대표적인 DAW중 하나인 Pro Tools와 그 위에서 동작하는 Plugin의 모습이다. 는 우리 회사 제품


JUCE가 멋진 또 하나의 이유는 바로 기본적으로 크로스플랫폼 빌드가 된다는 점이다. JUCE의 코드를 깊이 살펴보면 빌드 환경의 OS를 감지하여 Native UI를 지원하도록 만들어져 있다. 이를 통해 Plugin의 UI를 개발하는 시간 또한 엄청나게 단축시켜준다. 지원하는 OS는 Windows / Mac OS X / Linux는 기본이고 Android와 iOS 역시 지원한다. 하지만 모바일의 경우는 Native UI 컴포넌트를 사용하지는 못하고 JUCE Framework의 컴포넌트를 사용해야 한다. 


이미 크로스플랫폼 Framework의 세계에서는 Qt가 최강자로 군림하고 있지만 그렇다고 JUCE가 마냥 하위호환인 것만은 아니다. Audio Plugin 개발에 있어서는 JUCE를 따라올 만큼 편리한 Framework가없고, GUI 개발 기능 역시 QtCreator 못지 않게 강력하다. 또한 사용자가 원하는 IDE로 프로젝트를 자동으로 생성해주는데, 한 번에 여러 플랫폼의 프로젝트를 관리할 수 있기 때문에 크로스플랫폼 개발의 관점에서도 상당히 편리하다. 덕분에 지금 쓰고 있는 튜토리얼 코드도 회사의 Mac OS와 집의 Windows에서 모두 동일하게 동작한다.



같은 코드로 Ubuntu, Windows, Mac OS의 App을 동시에 개발한다.


이번 튜토리얼에서는 이 JUCE를 이용해서 UI윈도우를 가진 실행파일을 빠르게 만들어 볼 것이다. 그리고 하나의 JUCE 프로젝트 파일만으로도 여러 플랫폼에서 빌드할 수 있는 프로젝트 파일들이 만들어지는 것을 확인해보도록 하겠다.


1) JUCE 설치


JUCE는 기본적으로 무료 사용이 가능하다. 다만 실행 시 윈도우 오른쪽 하단에 잠깐 워터마크가 떴다가 사라진다는 차이만 있고 기능에 제약이 생기는 것은 없다. 다운로드는 아래 링크에서 받을 수 있다.



다운로드 받은 폴더를 열어보면 이런 구조로 되어있을 것이다. DemoRunner.exe를 잠깐 실행시켜보도록 할까.


여러 DSP 예제들이 준비되어 있다.

예전엔 없었는데 SIMD 예제가 추가됐다! 나중에 꼭 써봐야지

OpenGL 예제도 있다. 하지만 GLSL은 제일 낮은 버전이다.



예전엔 Demo도 상당히 빈약한 편이었는데 이제는 종류가 엄청 늘어났다. 이 데모들만 다 이해해도 JUCE를 전부 이해했다고 할 수 있을 정도로 잘 갖춰져 있다. 나중엔 TensorFlow Wrapper도 만들어지지 않을까 내심 기대해본다.



2) Project 생성


이번에는 Projucer.exe를 실행한다. 생성할 수 있는 프로젝트의 종류가 다양한데, 일종의 템플릿 형태기 때문에 꼭 이것을 통해서만 만들 수 있는 것은 아니다. GUI Application 프로젝트를 생성하더라도 설정을 바꾸거나 클래스를 상속받는 방법 등으로 얼마든지 Audio Application이나 다른 종류의 Application을 개발할 수 있다. 자세한 설명은 다음으로 미루고 오늘은 가장 첫 번째에 있는 GUI Application을 클릭해 보겠다.



오른쪽에 보이듯이, 다양한 IDE를 지원한다. 여러 개를 클릭할 수도 있어서 Windows에서도 Xcode project의 설정을 관리할 수 있다. Visual Studio는 2017까지 지원하고. Xcode도 9.1에서 문제 없이 돌아가는 것을 확인했다. 나머지는 잘 모르겠지만 잘 되겠지 ㅎ 적절한 프로젝트 이름을 붙여주고 다음으로 넘어간다.


아앗 시작부터 에러가


이런 메세지가 뜰 수 있는데, 프로젝트 설정창 오른쪽 상단에 있는 JUCE module의 경로가 잘못되어서 그렇다. 위에 있는 폴더 목록 사진을 보면 modules라는 폴더가 있다. 해당 경로를 Modules Folder로 지정해주면 에러가 사라진다.


조그마한 IDE처럼 생겼다. 실제로 간단한 코드 편집은 여기서 해도 될 정도로 편리하다.


자동으로 생성된 C++ 코드가 벌써 세 개나 있다. 천천히 뜯어보는 것은 나중으로 천천히 미뤄두고 일단은 상단에 있는 Visual Studio 아이콘을 세게 눌러보자.


몇 번의 클릭만으로도 벌써 근사한 프로젝트 솔루션 파일이 만들어져서 기분이 좋다. 어서 빨리 F6를 눌러 빌드가 잘 되는지 확인해보자.


1>------ 빌드 시작: 프로젝트: JUCEPractice1_App, 구성: Debug x64 ------
1>C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\Common7\IDE\VC\VCTargets\Platforms\x64\PlatformToolsets\v141\Toolset.targets(36,5): error MSB8036: Windows SDK 버전 10.0.16299.0을(를) 찾을 수 없습니다. 필요한 버전의 Windows SDK를 설치하거나, 솔루션을 마우스 오른쪽 단추로 클릭하고 [솔루션 대상 변경]을 선택하거나 프로젝트 속성 페이지에서 SDK 버전을 변경하세요.
1>"JUCEPractice1_App.vcxproj" 프로젝트를 빌드했습니다. - 실패
========== 빌드: 성공 0, 실패 1, 최신 0, 생략 0 ==========


하지만 시작부터 운이 좋지는 않았다. 이런 에러 메세지가 떴는데 아무래도 Windows SDK의 버전 문제인 것 같다. Visual Studio에서도 해결할 수 있지만 JUCE의 Project 설정을 통해 해결하면, 이후에도 지속적으로 적용시킬 수 있다. 우선은 현재 이 컴퓨터에 설치되어 있는 Windows SDK의 버전을 확인할 필요가 있다. 우선 현재 솔루션을 오른쪽 클릭해보자.



프로젝트 대상 변경, 영어로는 Project Daesang Byunkyeong...이 아니고 Retarget SDK Version이라는 항목이 있다. 얼른 클릭해보자.




그러면 현재 이 프로젝트에서 사용하고 있는 Windows SDK의 버전이 10.0.17134.0이라는 사실을 알 수 있다. 이 숫자를 외워둔 상태에서 아까 처음에 만들었었던 Juce 프로젝트 파일로 다시 가보자.



왼쪽의 메뉴에서 Exporters > Visual Studio 2017을 클릭하면 해당 솔루션 파일의 설정 값들 리스트가 나오는데, 아래로 스크롤 해보면 Windows Target Platform이라는 항목이 있다. 왠지 익숙한 듯 하지만 낯선 숫자가 적혀있다. 저 부분에 아까 확인했던 SDK 버전을 입력해주자. Visual Studio에 익숙한 사람이라면 위에 있는 Platform Toolset에도 자연스레 눈이 갈텐데, 저것을 다른 것으로 수정해주면 더 낮은 버전의 msbuild로도 빌드가 가능하도록 설정할 수 있다. 이런저런 설정들이 많으니 궁금하다면 하나씩 건드려보면 좋다. 





바꾼 설정을 저장하고, 다시 Visual Studio로 돌아가보면 다음과 같은 알림이 뜬다. 솔루션 다시 로드를 눌러서 통째로 Refresh를 해주자. JUCE 프로젝트에서 단순히 코드만을 수정하는 경우는 솔루션을 통째로 다시 로드 할 필요 없이 '다시 로드'만 눌러줘도 된다. 이제 빌드가 성공적으로 완료되는 것을 확인할 수 있다. 어서 빨리 F5를 눌러 실행을 시켜보자.




아, 언제 봐도 설레는 문장! 안녕!


코드는 한 줄도 치지 않고도 실제로 실행되는 윈도우를 만드는데 성공했다. 긴 천리길에서 이제 한 걸음을 내딛었지만 내심 뿌듯하다. 


3) 크로스 플랫폼 빌드


또 다시 몇 번의 클릭으로 이번에는 같은 프로그램을 Mac OS 버전으로 만들어 보겠다. 다시 Projucer로 돌아가자. 왼쪽 메뉴의 Exports 항목을 클릭한 뒤 빈 공간을 오른쪽 클릭하면 프로젝트를 생성할 수 있느 IDE의 목록이 나온다. 여기서 Xcode(MacOSX) 항목을 선택한다.


다 해줄 줄 알았지만 안 되는건 안 되는거다.

혹시나 하는 기대에 부풀었을지도 모르겠지만 애석하게도 JUCE가 Windows에서 Mac OS App 빌드까지 지원해주지는 않는다. 사실 애초에 불가능 한 일이기도 하다. Windows에서 할 수 있는건 다만 Xcode 프로젝트 설정을 추가하고 저장하는 것 뿐이다. 이제 이렇게 만들어진 Projucer파일을 Mac OS로 옮겨야 한다. 프로젝트를 생성한 폴더를 보면 *.jucer라는 확장자의 파일이 있다. 이것이 JUCE의 프로젝트 파일이다. 프로젝트 파일 뿐만 아니라 소스코드까지 모두 옮겨야 하기 때문에 jucer 파일이 있는 폴더를 통째로 압축하자.


미리 준비해뒀던 맥북을 꺼내 아까 압축했던 파일을 USB에 담아 원하는 위치에 옮긴다. 아 물론 Mac OS에도 Projucer가 설치되어 있어야 한다는걸 잊지 말자. 설치 과정은 Windows와 정확히 같다.


Projucer를 열어보자. UI의 LookAndFeel이 Windows와 완전히 같은 모양으로 만들어져 있다. 별거 아닌 것 같지만 감동스러운 부분이다. 이번에는 상단의 Xcode 버튼이 활성화 되어있다! 세게 눌러주자.



전체적인 파일 구조 역시 Visual Studio에서 열었을때와 동일하다. Same Code Different Platform이 매우 잘 구현되어 있음을 알 수 있다. 왼쪽 상단의 화살표 버튼을 눌러 빌드 후 실행을 해보자.


또 만났네 안녕!

이 과정까지도 여전히 코드는 한 줄도 치지 않고 왔지만. Mac OS에서도 문제없이 잘 실행된다! 이제 본격적으로 코드를 작성할 준비가 갖춰졌다. 다음편에서는 이렇게 만들어진 어디서든 인사를 건네는 다정한 프로그램의 내부를 좀 더 살펴보면서 JUCE Framework의 구조에 대해 소개하는 시간을 갖도록 할 것이다.


+ Recent posts