고급 언어와 가상머신 기반 런타임이 대부분의 애플리케이션에서 합리적인 선택이지만, 하드웨어의 연산 능력을 마지막 한 방울까지 짜내야 하는 영역은 여전히 존재한다. 게임, 실시간 미디어 처리, 대규모 데이터센터, 배터리 수명이 관건인 모바일 기기가 대표적이다. 이런 곳에서는 네이티브 언어를 고르는 것만으로 충분하지 않고, 하드웨어가 실제로 어떻게 동작하는지에 대한 이해와 좋은 관행이 함께 필요하다. 폴란드 프로그래밍 잡지에 실렸던 이 글은 C++를 예로 들어 그 관행들을 정리한다.
C++가 이런 작업에 자주 선택되는 이유는 어정쩡해 보이지만 실은 강력한 절충점에 있다. 객체지향과 STL 컨테이너 같은 편의를 제공하는 고수준 언어이면서, 동시에 가상머신이나 프레임워크 없이 운영체제와 하드웨어에 가깝게 접근할 수 있는 저수준 언어이기도 하다. 메모리 할당과 해제를 직접 책임져야 하지만, 그 대가로 가비지 컬렉터가 예측 불가능한 순간에 개입하는 일도 없다. 게임 콘솔처럼 CPU 속도와 RAM이 고정된 환경, 혹은 캐주얼 게임처럼 최신 사양을 요구할 수 없는 상황에서는 이 통제권이 곧 성능이 된다.
객체지향을 맹신할 때 생기는 비용
글의 핵심 주장은 객체지향 프로그래밍을 기술이 아니라 철학으로만 받아들일 때 성능에서 멀어진다는 것이다. 서로 다른 클래스의 작은 객체들이 포인터로 얽혀 메모리 곳곳에 흩어져 있는 구조가 전형적인 문제다. 이런 데이터를 순회하면 포인터를 따라 이곳저곳으로 '점프'하게 되어 캐시 미스가 빈번해진다. 게다가 공개된 (흔히 가상) 메서드 뒤에서 무슨 일이 벌어지는지, 그 객체가 내부에서 또 어떤 객체를 참조하는지 알 수 없다면 여러 스레드에서 안전하게 실행할 수 없다. 결국 뮤텍스로 공유 데이터를 보호하게 되고, 이는 코드를 사실상 직렬화해 병렬성의 이점을 없앤다.
그 대안으로 제시되는 사고방식이 데이터 지향 설계(Data-Oriented Design)다. 최근 게임 프로그래머들 사이에서 부상한 이 용어는 알고리즘보다 먼저 데이터의 메모리 배치와 자료구조 설계에 집중하자는 태도를 뜻한다. 구조적 프로그래밍으로의 회귀처럼 보일 수 있지만 클래스 사용을 배제하지는 않는다. 배열처럼 규칙적인 자료구조를 정렬·갱신·삭제·변환하되, 각 원소에 대한 연산이 다른 원소와 독립적이라면 범위를 스레드에 나눠 손쉽게 병렬화할 수 있다는 것이 요점이다.
저자는 객체지향을 기계적으로 따를 때의 부수적 함정도 지적한다. 라이브러리를 쓸 때마다 자신만의 래퍼로 감싸 인터페이스를 '더 우아하게' 만들려는 습관, 모든 것을 가상 메서드 인터페이스로 일반화해 언젠가 구현을 통째로 교체할 수 있게 해두려는 유혹, 디자인 패턴을 깊은 고민의 대체물로 남용하는 관행이 그것이다. 이런 층위 하나하나가 런타임 오버헤드를 더한다. 프로그램이 저장해야 할 데이터와 그 데이터에 수행할 연산을 가능한 한 직접적으로 표현하면 코드는 오히려 단순하고 읽기 쉬우면서 효율적이 된다. 최적화가 곧 난독화라는 통념과 정반대의 이야기다.
캐시의 동작을 이해해야 하는 이유
왜 메모리 배치가 그토록 중요한지는 하드웨어의 현실에서 나온다. 1980년대 프로세서는 RAM에 직접 접근해 단일 사이클에 연산을 끝낼 수 있었지만, 오늘날은 연산 속도가 메모리 속도보다 훨씬 빠르게 향상되어 그 격차가 계속 벌어지고 있다. 현대 컴퓨터에서 메인 메모리로부터 단 1바이트를 읽는 데 걸리는 시간이 수백 사이클에 맞먹는다. 여기서 데이터 전송률을 뜻하는 대역폭과 요청한 데이터가 도착하기까지의 시간을 뜻하는 지연(latency)은 서로 다른 개념임을 구분해야 한다.
이 간극을 메우려 등장한 것이 캐시이며, 용량은 크지만 느려지는 여러 계층으로 이뤄진 메모리 계층이 존재한다. 3GHz 프로세서라면 덧셈 한 번이 약 0.33ns인데 L1 캐시 접근이 1ns라면 이는 세 개의 명령을 실행하는 지연과 같다. 캐시는 우리가 직접 제어하지 않고 자동으로 관리되지만, 그 원리는 알아야 한다. RAM의 데이터를 읽을 때는 1바이트씩이 아니라 예컨대 64바이트짜리 캐시 라인 전체가 통째로 올라온다. 따라서 함께 자주 쓰이는 값을 메모리상에서 서로 가까이 배치하면 코드가 필요로 할 때 이미 캐시에 들어와 있을 가능성이 높아진다. 반대로 따로따로 할당해 흩어놓은 작은 객체들, 그리고 포인터를 따라 순회하는 연결 리스트·트리·그래프는 캐시 미스로 인한 성능 저하에 취약하다.
구체적인 예로, 한 번 만든 뒤 정렬된 순서로 순회하고 빠르게 검색해야 하는 객체 컬렉션을 생각해보자. 순서 유지를 위해 std::set이나 std::map 같은 이진 트리 계열을 떠올리기 쉽지만, 이들은 각 원소를 힙에 개별 할당하고 포인터로 잇기 때문에 검색의 점근적 복잡도는 좋아도 상수 인자가 커서 실제 성능이 나쁠 수 있다. 삽입과 삭제가 더 이상 필요 없다면 그냥 배열을 쓰고, 모든 원소를 넣은 뒤 한 번 정렬한 다음 이진 탐색으로 로그 시간에 찾는 편이 이론적 복잡도는 동등하면서 순회는 훨씬 빠르다. 연속된 원소가 메모리에서 나란히 놓이기 때문이다. 저자는 이런 계층 개념을 '성능 피라미드'로 확장해, 각 하위 단계의 연산이 상위보다 최소 한 자릿수 느리다는 점을 항상 의식하고 하위 단계 연산을 자주 수행하지 않도록 하라고 조언한다. 결국 효율적인 코드란 영리한 트릭이 아니라, 데이터가 하드웨어에서 어떻게 흐르는지를 설계 단계에서부터 염두에 두는 태도에서 나온다.